Compare commits

...
11 Commits
Author SHA1 Message Date
soconnorandClaude Opus 4.8 3f3b1362a9 Fix clock-in failing when no client is selected
clockIn and create used `input.clientId?.trim() ?? null`, but clients
always send `""` (not undefined) for no-client. `??` doesn't catch the
empty string, so `""` was inserted into time_entries.client_id,
violating the foreign key to clients.id. Use `|| null` to normalize a
blank string to null, matching updateRunning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:43:21 -04:00
soconnor 40624f6e6f Document recurring invoice scheduler setup 2026-07-02 00:49:48 -04:00
soconnorandClaude Sonnet 5 d589b94580 Make CRON_SECRET optional; fix docker env validation failure
CRON_SECRET was required (min 32 chars) in production by env.js, but
neither docker-compose.yml nor docker-compose.coolify.yml passed it
through. Since the release container isn't covered by
SKIP_ENV_VALIDATION (build-stage only), bun run start failed env
validation on every docker deploy unless CRON_SECRET was manually
configured — even though it only gates the optional recurring-invoice
cron endpoint, which already handles being unset gracefully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:43:20 -04:00
soconnor da219b287e Support mobile home and live activity data 2026-07-01 02:55:16 -04:00
soconnor 316edb4b3e Prefer mobile auth cookie for app requests 2026-06-29 22:01:41 -04:00
soconnor 8866335c50 Accept mobile Better Auth session token header 2026-06-29 18:38:35 -04:00
soconnor fb731885de Support chunked Better Auth session cookies 2026-06-29 17:57:38 -04:00
soconnor 1605322bc9 Improve expense dialog button layout 2026-06-29 15:30:19 -04:00
soconnor ef826dffa9 Harden auth and mobile session handling 2026-06-29 15:21:31 -04:00
soconnor d0c916d659 Polish expenses dashboard 2026-06-29 01:30:20 -04:00
soconnor a66d05f24f Remove default access and secret keys for S3 in Docker Compose files for security enhancement 2026-06-29 01:15:05 -04:00
27 changed files with 865 additions and 363 deletions
+12
View File
@@ -141,6 +141,18 @@ docker compose build --no-cache app
App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network. App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
### Scheduled recurring invoices
The app container does not run a cron daemon. It starts the web server with
`bun migrate.ts && bun run start`, and recurring invoice generation only happens
when something calls `POST /api/cron/generate-recurring` with
`Authorization: Bearer $CRON_SECRET`.
- **Coolify deploys:** use a Coolify scheduled task to call the endpoint.
- **Full Docker deploys:** use host cron, a small scheduler sidecar, or an
external scheduler to call
`http://localhost:${WEB_PORT:-${PORT:-3000}}/api/cron/generate-recurring`.
### 3. Updating an existing deploy ### 3. Updating an existing deploy
```bash ```bash
+3 -3
View File
@@ -21,8 +21,8 @@ services:
garage: garage:
image: dxflrs/garage:v2.3.0 image: dxflrs/garage:v2.3.0
environment: environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558} GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34} GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts} GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900: SERVICE_FQDN_GARAGE_3900:
configs: configs:
@@ -56,7 +56,7 @@ configs:
rpc_bind_addr = "[::]:3901" rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901" rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" rpc_secret = "rpc_secret_change_me_in_production"
[s3_api] [s3_api]
s3_region = "garage" s3_region = "garage"
+6 -5
View File
@@ -32,14 +32,15 @@ services:
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true} DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-} AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://garage:3900 S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts} S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558} S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34} S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-garage} S3_REGION: ${S3_REGION:-garage}
expose: expose:
- "3000" - "3000"
@@ -69,8 +70,8 @@ services:
garage: garage:
image: dxflrs/garage:v2.3.0 image: dxflrs/garage:v2.3.0
environment: environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558} GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34} GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts} GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900: SERVICE_FQDN_GARAGE_3900:
configs: configs:
@@ -105,7 +106,7 @@ configs:
rpc_bind_addr = "[::]:3901" rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901" rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" rpc_secret = "rpc_secret_change_me_in_production"
[s3_api] [s3_api]
s3_region = "garage" s3_region = "garage"
+1
View File
@@ -31,6 +31,7 @@ services:
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true} DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
+19 -2
View File
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { users } from "~/server/db/schema";
import { sendPasswordResetForUser } from "~/lib/password-reset"; import { sendPasswordResetForUser } from "~/lib/password-reset";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -12,8 +13,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Email is required" }, { status: 400 }); return NextResponse.json({ error: "Email is required" }, { status: 400 });
} }
const normalizedEmail = email.toLowerCase().trim();
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:forgot"), {
windowMs: 60 * 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:forgot-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) { if (!emailRegex.test(normalizedEmail)) {
return NextResponse.json( return NextResponse.json(
{ error: "Invalid email format" }, { error: "Invalid email format" },
{ status: 400 }, { status: 400 },
@@ -21,7 +38,7 @@ export async function POST(request: NextRequest) {
} }
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()), where: eq(users.email, normalizedEmail),
columns: { id: true }, columns: { id: true },
}); });
+17 -1
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { auth } from "~/lib/auth"; import { auth } from "~/lib/auth";
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors"; import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
import { resolveNewUserRole } from "~/lib/first-admin"; import { resolveNewUserRole } from "~/lib/first-admin";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { env } from "~/env"; import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
@@ -71,6 +72,12 @@ function formatRegisterError(error: z.ZodError): string {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
windowMs: 60 * 60 * 1000,
max: 5,
});
if (rateLimit) return rateLimit;
if (env.DISABLE_SIGNUPS === true) { if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json( return NextResponse.json(
{ error: "New account registration is currently disabled" }, { error: "New account registration is currently disabled" },
@@ -106,13 +113,22 @@ export async function POST(request: NextRequest) {
const { firstName, lastName, email, password } = parsed.data; const { firstName, lastName, email, password } = parsed.data;
const normalizedEmail = email.toLowerCase(); const normalizedEmail = email.toLowerCase();
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:register-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.email, normalizedEmail), where: eq(users.email, normalizedEmail),
}); });
if (existingUser) { if (existingUser) {
return NextResponse.json( return NextResponse.json(
{ error: "User with this email already exists" }, { error: "Registration failed. Please check the form or sign in." },
{ status: 400 }, { status: 400 },
); );
} }
+23 -1
View File
@@ -1,11 +1,20 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { revokeUserSessions } from "~/lib/session-security";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), {
windowMs: 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const { token, password } = (await request.json()) as { const { token, password } = (await request.json()) as {
token: string; token: string;
password: string; password: string;
@@ -29,10 +38,21 @@ export async function POST(request: NextRequest) {
); );
} }
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired // Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: and( where: and(
eq(users.resetToken, token), eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()), gt(users.resetTokenExpiry, new Date()),
), ),
}); });
@@ -82,6 +102,8 @@ export async function POST(request: NextRequest) {
} }
}); });
await revokeUserSessions(user.id);
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
+20 -1
View File
@@ -1,20 +1,39 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:validate-reset"), {
windowMs: 60 * 1000,
max: 20,
});
if (ipRateLimit) return ipRateLimit;
const { token } = (await request.json()) as { token: string }; const { token } = (await request.json()) as { token: string };
if (!token || typeof token !== "string") { if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token is required" }, { status: 400 }); return NextResponse.json({ error: "Token is required" }, { status: 400 });
} }
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:validate-reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired // Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: and( where: and(
eq(users.resetToken, token), eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()), gt(users.resetTokenExpiry, new Date()),
), ),
}); });
+8 -1
View File
@@ -7,7 +7,14 @@ export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization"); const authHeader = req.headers.get("authorization");
const secret = env.CRON_SECRET; const secret = env.CRON_SECRET;
if (secret && authHeader !== `Bearer ${secret}`) { if (!secret) {
return NextResponse.json(
{ error: "Cron secret is not configured" },
{ status: 500 },
);
}
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
+2 -1
View File
@@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { env } from "~/env"; import { env } from "~/env";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
import { safeCallbackPath } from "~/lib/safe-callback-url";
import { toast } from "sonner"; import { toast } from "sonner";
interface SignInFormProps { interface SignInFormProps {
@@ -25,7 +26,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
+178 -55
View File
@@ -3,7 +3,10 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page"; import {
DashboardPage,
dashboardStatGridClass,
} from "~/components/layout/dashboard-page";
import { EmptyState } from "~/components/layout/page-layout"; import { EmptyState } from "~/components/layout/page-layout";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -31,9 +34,22 @@ import { NumberInput } from "~/components/ui/number-input";
import { ExpenseReceiptsPanel } from "~/components/expenses/expense-receipts-panel"; import { ExpenseReceiptsPanel } from "~/components/expenses/expense-receipts-panel";
import { ExpenseReceiptIndicator } from "~/components/expenses/expense-receipt-indicator"; import { ExpenseReceiptIndicator } from "~/components/expenses/expense-receipt-indicator";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, Receipt, Eye } from "lucide-react"; import {
MoreHorizontal,
Pencil,
Plus,
Receipt,
Search,
Trash2,
} from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency"; import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories"; import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
interface ExpenseFormData { interface ExpenseFormData {
date: Date; date: Date;
@@ -64,6 +80,7 @@ const defaultForm: ExpenseFormData = {
}; };
type ExpenseDialogMode = "create" | "view" | "edit"; type ExpenseDialogMode = "create" | "view" | "edit";
type ExpenseFilter = "all" | "billable" | "deductible" | "receipts";
function expenseToForm( function expenseToForm(
expense: { expense: {
@@ -103,6 +120,8 @@ export default function ExpensesPage() {
const [form, setForm] = useState<ExpenseFormData>(defaultForm); const [form, setForm] = useState<ExpenseFormData>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [businessFilter, setBusinessFilter] = useState("all"); const [businessFilter, setBusinessFilter] = useState("all");
const [expenseFilter, setExpenseFilter] = useState<ExpenseFilter>("all");
const [search, setSearch] = useState("");
const utils = api.useUtils(); const utils = api.useUtils();
const { data: businesses = [] } = api.businesses.getAll.useQuery(); const { data: businesses = [] } = api.businesses.getAll.useQuery();
@@ -192,7 +211,29 @@ export default function ExpensesPage() {
else create.mutate(payload); else create.mutate(payload);
}; };
const filteredExpenses = useMemo(() => {
const needle = search.trim().toLowerCase();
return expenses.filter((expense) => {
if (expenseFilter === "billable" && !expense.billable) return false;
if (expenseFilter === "deductible" && !expense.taxDeductible)
return false;
if (expenseFilter === "receipts" && expense.receiptCount === 0)
return false;
if (!needle) return true;
return [
expense.description,
expense.category,
expense.notes,
expense.business?.name,
expense.client?.name,
]
.filter(Boolean)
.some((value) => value?.toLowerCase().includes(needle));
});
}, [expenseFilter, expenses, search]);
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const visibleTotal = filteredExpenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses const billableTotal = expenses
.filter((e) => e.billable) .filter((e) => e.billable)
.reduce((s, e) => s + e.amount, 0); .reduce((s, e) => s + e.amount, 0);
@@ -200,6 +241,10 @@ export default function ExpensesPage() {
.filter((e) => e.taxDeductible) .filter((e) => e.taxDeductible)
.reduce((s, e) => s + e.amount, 0); .reduce((s, e) => s + e.amount, 0);
const withReceipts = expenses.filter((e) => e.receiptCount > 0).length; const withReceipts = expenses.filter((e) => e.receiptCount > 0).length;
const hasActiveFilters =
search.trim().length > 0 ||
expenseFilter !== "all" ||
businessFilter !== "all";
const isViewMode = dialogMode === "view"; const isViewMode = dialogMode === "view";
const isEditMode = dialogMode === "edit"; const isEditMode = dialogMode === "edit";
@@ -239,23 +284,6 @@ export default function ExpensesPage() {
</Button> </Button>
</DashboardPageHeader> </DashboardPageHeader>
<div className="mb-4 flex items-center gap-3">
<Label className="text-sm">Business</Label>
<Select value={businessFilter} onValueChange={setBusinessFilter}>
<SelectTrigger className="w-52">
<SelectValue placeholder="All businesses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All businesses</SelectItem>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={dashboardStatGridClass}> <div className={dashboardStatGridClass}>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
@@ -298,10 +326,64 @@ export default function ExpensesPage() {
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader className="gap-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" /> All Expenses <Receipt className="h-5 w-5" /> Expenses
</CardTitle> </CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
{filteredExpenses.length === expenses.length
? `${expenses.length} recorded`
: `${filteredExpenses.length} of ${expenses.length} shown`}
{filteredExpenses.length !== expenses.length
? ` · ${formatCurrency(visibleTotal)} visible`
: ""}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative sm:w-64">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search expenses"
className="pl-9"
/>
</div>
<Select value={businessFilter} onValueChange={setBusinessFilter}>
<SelectTrigger className="sm:w-52">
<SelectValue placeholder="All businesses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All businesses</SelectItem>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-2">
{[
["all", "All"] as const,
["billable", "Billable"] as const,
["deductible", "Deductible"] as const,
["receipts", "With receipts"] as const,
].map(([value, label]) => (
<Button
key={value}
type="button"
variant={expenseFilter === value ? "default" : "outline"}
size="sm"
onClick={() => setExpenseFilter(value)}
>
{label}
</Button>
))}
</div>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
@@ -320,16 +402,36 @@ export default function ExpensesPage() {
</Button> </Button>
} }
/> />
) : filteredExpenses.length === 0 ? (
<EmptyState
icon={<Search className="h-6 w-6" />}
title="No matching expenses"
description="Adjust the search or filters to bring expenses back into view."
action={
hasActiveFilters ? (
<Button
variant="outline"
onClick={() => {
setSearch("");
setExpenseFilter("all");
setBusinessFilter("all");
}}
>
Clear filters
</Button>
) : undefined
}
/>
) : ( ) : (
<> <>
<div className="text-muted-foreground hidden border-b px-4 py-2 text-xs font-medium tracking-wide uppercase sm:grid sm:grid-cols-[1fr_88px_96px_auto] sm:gap-3"> <div className="text-muted-foreground hidden border-b px-4 py-2 text-xs font-medium tracking-wide uppercase sm:grid sm:grid-cols-[minmax(0,1fr)_104px_116px_44px] sm:gap-3">
<span>Expense</span> <span>Expense</span>
<span className="text-center">Receipts</span> <span className="text-center">Receipts</span>
<span className="text-right">Amount</span> <span className="text-right">Amount</span>
<span className="w-[108px]" /> <span />
</div> </div>
<div className="divide-y"> <div className="divide-y">
{expenses.map((expense) => ( {filteredExpenses.map((expense) => (
<div <div
key={expense.id} key={expense.id}
role="button" role="button"
@@ -341,7 +443,7 @@ export default function ExpensesPage() {
handleView(expense); handleView(expense);
} }
}} }}
className="hover:bg-muted/40 focus-visible:ring-ring flex cursor-pointer flex-col gap-3 p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none sm:grid sm:grid-cols-[1fr_88px_96px_auto] sm:items-start sm:gap-3" className="hover:bg-muted/40 focus-visible:ring-ring flex cursor-pointer flex-col gap-3 p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none sm:grid sm:grid-cols-[minmax(0,1fr)_104px_116px_44px] sm:items-center sm:gap-3"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -400,42 +502,43 @@ export default function ExpensesPage() {
/> />
</div> </div>
<div className="flex items-center justify-between sm:contents">
<p className="font-semibold sm:text-right"> <p className="font-semibold sm:text-right">
{formatCurrency(expense.amount, expense.currency)} {formatCurrency(expense.amount, expense.currency)}
</p> </p>
<div <div
className="flex flex-shrink-0 items-center gap-1 sm:gap-2" className="flex justify-end"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 p-0" className="h-9 w-9 p-0"
onClick={() => handleView(expense)} aria-label={`Actions for ${expense.description}`}
title="View expense"
> >
<Eye className="h-3.5 w-3.5" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
<Button </DropdownMenuTrigger>
variant="ghost" <DropdownMenuContent align="end">
size="sm" <DropdownMenuItem onClick={() => handleView(expense)}>
className="h-8 w-8 p-0" <Receipt className="mr-2 h-4 w-4" />
onClick={() => handleEdit(expense)} View details
title="Edit expense" </DropdownMenuItem>
> <DropdownMenuItem onClick={() => handleEdit(expense)}>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="mr-2 h-4 w-4" />
</Button> Edit
<Button </DropdownMenuItem>
variant="ghost" <DropdownMenuItem
size="sm" variant="destructive"
className="text-destructive h-8 w-8 p-0"
onClick={() => setDeleteId(expense.id)} onClick={() => setDeleteId(expense.id)}
title="Delete expense"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="mr-2 h-4 w-4" />
</Button> Delete
</div> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
))} ))}
@@ -564,7 +667,9 @@ export default function ExpensesPage() {
<Label>Currency</Label> <Label>Currency</Label>
<Select <Select
value={form.currency} value={form.currency}
onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))} onValueChange={(v) =>
setForm((p) => ({ ...p, currency: v }))
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -595,7 +700,10 @@ export default function ExpensesPage() {
<Select <Select
value={form.category || "none"} value={form.category || "none"}
onValueChange={(v) => onValueChange={(v) =>
setForm((p) => ({ ...p, category: v === "none" ? "" : v })) setForm((p) => ({
...p,
category: v === "none" ? "" : v,
}))
} }
> >
<SelectTrigger> <SelectTrigger>
@@ -642,7 +750,10 @@ export default function ExpensesPage() {
<Select <Select
value={form.clientId || "none"} value={form.clientId || "none"}
onValueChange={(v) => onValueChange={(v) =>
setForm((p) => ({ ...p, clientId: v === "none" ? "" : v })) setForm((p) => ({
...p,
clientId: v === "none" ? "" : v,
}))
} }
> >
<SelectTrigger> <SelectTrigger>
@@ -702,23 +813,35 @@ export default function ExpensesPage() {
<ExpenseReceiptsPanel expenseId={editId} readOnly={isViewMode} /> <ExpenseReceiptsPanel expenseId={editId} readOnly={isViewMode} />
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-3">
{isViewMode ? ( {isViewMode ? (
<> <>
<Button variant="outline" onClick={closeDialog}> <Button
variant="outline"
className="w-full sm:w-auto"
onClick={closeDialog}
>
Close Close
</Button> </Button>
<Button onClick={() => setDialogMode("edit")}> <Button
className="w-full sm:w-auto"
onClick={() => setDialogMode("edit")}
>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
Edit Edit
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<Button variant="outline" onClick={closeDialog}> <Button
variant="outline"
className="w-full sm:w-auto"
onClick={closeDialog}
>
Cancel Cancel
</Button> </Button>
<Button <Button
className="w-full sm:w-auto"
onClick={handleSubmit} onClick={handleSubmit}
disabled={create.isPending || update.isPending} disabled={create.isPending || update.isPending}
> >
+4 -1
View File
@@ -34,7 +34,10 @@ export const env = createEnv({
.default("development"), .default("development"),
DB_DISABLE_SSL: optionalEnvBoolean(), DB_DISABLE_SSL: optionalEnvBoolean(),
DISABLE_SIGNUPS: optionalEnvBoolean().default(true), DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
CRON_SECRET: z.string().optional(), // Optional — only gates POST /api/cron/generate-recurring; the route itself
// returns a clean error when unset, so deployments that don't use recurring
// invoices don't need to configure it.
CRON_SECRET: z.string().min(32).optional(),
// S3-compatible object storage (optional — local .data/receipts/ fallback when unset) // S3-compatible object storage (optional — local .data/receipts/ fallback when unset)
S3_ENDPOINT: z.string().url().optional(), S3_ENDPOINT: z.string().url().optional(),
S3_BUCKET: z.string().optional(), S3_BUCKET: z.string().optional(),
+63 -6
View File
@@ -1,19 +1,76 @@
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { auth } from "~/lib/auth"; import { auth } from "~/lib/auth";
const MOBILE_AUTH_COOKIE_HEADER = "x-beenvoice-auth-cookie";
const MOBILE_SESSION_TOKEN_HEADER = "x-beenvoice-session-token";
const MAX_AUTH_COOKIE_HEADER_LENGTH = 16 * 1024;
const MAX_SESSION_TOKEN_LENGTH = 255;
const SESSION_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]+$/;
function looksLikeSessionCookie(cookie: string): boolean {
return cookie.split(";").some((part) => {
const name =
part
.trim()
.split("=", 1)[0]
?.replace(/^__Secure-/, "") ?? "";
return (
name === "better-auth.session_token" ||
name === "better-auth.session_data" ||
name.startsWith("better-auth.session_token.") ||
name.startsWith("better-auth.session_data.") ||
name.endsWith(".session_token") ||
name.endsWith(".session_data") ||
name.includes(".session_token.") ||
name.includes(".session_data.")
);
});
}
export function headersWithAuthCookieFallback(headers: Headers): Headers {
const mobileCookie = headers.get(MOBILE_AUTH_COOKIE_HEADER)?.trim();
if (
mobileCookie &&
mobileCookie.length <= MAX_AUTH_COOKIE_HEADER_LENGTH &&
looksLikeSessionCookie(mobileCookie)
) {
const nextHeaders = new Headers(headers);
nextHeaders.set("cookie", mobileCookie);
return nextHeaders;
}
if (headers.get("cookie")?.trim()) return headers;
const sessionToken = headers.get(MOBILE_SESSION_TOKEN_HEADER)?.trim();
if (
sessionToken &&
sessionToken.length <= MAX_SESSION_TOKEN_LENGTH &&
SESSION_TOKEN_PATTERN.test(sessionToken)
) {
const nextHeaders = new Headers(headers);
nextHeaders.set(
"cookie",
[
`better-auth.session_token=${sessionToken}`,
`__Secure-better-auth.session_token=${sessionToken}`,
].join("; "),
);
return nextHeaders;
}
return headers;
}
export function hasSessionCookie(headers: Headers): boolean { export function hasSessionCookie(headers: Headers): boolean {
const cookie = headers.get("cookie") ?? ""; const cookie = headers.get("cookie") ?? "";
if (!cookie.trim()) return false; if (!cookie.trim()) return false;
return ( return looksLikeSessionCookie(cookie);
cookie.includes("session_token=") ||
cookie.includes("session_data=") ||
cookie.includes("better-auth.session_token=") ||
cookie.includes("__Secure-better-auth.session_token=")
);
} }
export async function getOptionalServerSession(headers: Headers) { export async function getOptionalServerSession(headers: Headers) {
headers = headersWithAuthCookieFallback(headers);
if (!hasSessionCookie(headers)) { if (!hasSessionCookie(headers)) {
return null; return null;
} }
+38 -20
View File
@@ -5,6 +5,7 @@ import { nextCookies } from "better-auth/next-js";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { env } from "~/env"; import { env } from "~/env";
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin"; import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
import { sendPasswordResetEmail } from "~/lib/password-reset";
import { db } from "~/server/db"; import { db } from "~/server/db";
import * as schema from "~/server/db/schema"; import * as schema from "~/server/db/schema";
@@ -26,7 +27,9 @@ const staticTrustedOrigins = [
...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []), ...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []), ...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
"beenvoice://", "beenvoice://",
"exp://", ...(env.NODE_ENV === "development"
? ["exp://", "http://localhost:3000", "http://127.0.0.1:3000"]
: []),
...(authentikOrigin ? [authentikOrigin] : []), ...(authentikOrigin ? [authentikOrigin] : []),
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []), ...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
]; ];
@@ -37,6 +40,29 @@ export const auth = betterAuth({
advanced: { advanced: {
trustedProxyHeaders: true, trustedProxyHeaders: true,
}, },
rateLimit: {
enabled: true,
window: 60,
max: 100,
customRules: {
"/sign-in/email": {
window: 60,
max: 10,
},
"/sign-up/email": {
window: 60 * 60,
max: 5,
},
"/request-password-reset": {
window: 60 * 60,
max: 5,
},
"/reset-password": {
window: 60,
max: 10,
},
},
},
experimental: { experimental: {
joins: true, joins: true,
}, },
@@ -61,25 +87,7 @@ export const auth = betterAuth({
}, },
}, },
}, },
trustedOrigins: async (request) => { trustedOrigins: staticTrustedOrigins,
const origins = [...staticTrustedOrigins];
if (!request) return origins;
const origin = request.headers.get("origin");
if (origin) origins.push(origin);
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https";
if (forwardedHost) {
for (const host of forwardedHost.split(",")) {
const trimmed = host.trim();
if (trimmed) origins.push(`${forwardedProto}://${trimmed}`);
}
}
return origins;
},
...(authentikEnabled && { ...(authentikEnabled && {
accountLinking: { accountLinking: {
enabled: true, enabled: true,
@@ -89,6 +97,16 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
disableSignUp: signupsDisabled, disableSignUp: signupsDisabled,
minPasswordLength: 8,
resetPasswordTokenExpiresIn: 60 * 60,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, token }) => {
await sendPasswordResetEmail({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken: token,
});
},
password: { password: {
hash: async (password) => { hash: async (password) => {
const bcrypt = await import("bcryptjs"); const bcrypt = await import("bcryptjs");
+48 -32
View File
@@ -1,10 +1,13 @@
import crypto from "crypto";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Resend } from "resend"; import { Resend } from "resend";
import { env } from "~/env"; import { env } from "~/env";
import { APP_EMAIL_DOMAIN } from "~/lib/app-email"; import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url"; import { getAppUrl } from "~/lib/app-url";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates"; import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import {
createPasswordResetToken,
hashPasswordResetToken,
} from "~/lib/reset-token";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { users } from "~/server/db/schema";
@@ -14,6 +17,45 @@ export type PasswordResetResult = {
userEmail?: string; userEmail?: string;
}; };
export async function sendPasswordResetEmail(input: {
userEmail: string;
userName?: string;
resetToken: string;
}): Promise<PasswordResetResult> {
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return { success: true, emailSent: false, userEmail: input.userEmail };
}
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: input.userEmail,
userName: input.userName,
resetToken: input.resetToken,
resetUrl,
expiryHours: 1,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: input.userEmail,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
return { success: true, emailSent: true, userEmail: input.userEmail };
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
return { success: true, emailSent: false, userEmail: input.userEmail };
}
}
export async function sendPasswordResetForUser( export async function sendPasswordResetForUser(
userId: string, userId: string,
): Promise<PasswordResetResult> { ): Promise<PasswordResetResult> {
@@ -26,44 +68,18 @@ export async function sendPasswordResetForUser(
return { success: false, emailSent: false }; return { success: false, emailSent: false };
} }
const resetToken = crypto.randomBytes(32).toString("hex"); const resetToken = createPasswordResetToken();
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); const resetTokenHash = hashPasswordResetToken(resetToken);
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
await db await db
.update(users) .update(users)
.set({ resetToken, resetTokenExpiry }) .set({ resetToken: resetTokenHash, resetTokenExpiry })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) { return sendPasswordResetEmail({
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return { success: true, emailSent: false, userEmail: user.email };
}
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: user.email, userEmail: user.email,
userName: user.name ?? undefined, userName: user.name ?? undefined,
resetToken, resetToken,
resetUrl,
expiryHours: 24,
}); });
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: user.email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
return { success: true, emailSent: true, userEmail: user.email };
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
return { success: true, emailSent: false, userEmail: user.email };
}
} }
+72
View File
@@ -0,0 +1,72 @@
import { createHash } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
type RateLimitRule = {
windowMs: number;
max: number;
};
type RateLimitRecord = {
count: number;
resetAt: number;
};
const buckets = new Map<string, RateLimitRecord>();
function clientIp(request: NextRequest) {
return (
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-real-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"unknown"
);
}
export function hashRateLimitPart(value: string) {
return createHash("sha256").update(value).digest("hex");
}
export function rateLimitKey(request: NextRequest, scope: string, subject?: string) {
const parts = [scope, clientIp(request)];
if (subject) parts.push(hashRateLimitPart(subject.toLowerCase().trim()));
return parts.join(":");
}
function retryAfterSeconds(resetAt: number) {
return Math.max(1, Math.ceil((resetAt - Date.now()) / 1000));
}
export function checkRateLimit(key: string, rule: RateLimitRule) {
const now = Date.now();
const existing = buckets.get(key);
if (!existing || existing.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + rule.windowMs });
return { allowed: true, retryAfter: 0 };
}
existing.count += 1;
if (existing.count <= rule.max) {
return { allowed: true, retryAfter: 0 };
}
return { allowed: false, retryAfter: retryAfterSeconds(existing.resetAt) };
}
export function rateLimitResponse(retryAfter: number) {
return NextResponse.json(
{ error: "Too many attempts. Please wait and try again." },
{
status: 429,
headers: {
"Retry-After": String(retryAfter),
"X-RateLimit-Retry-After": String(retryAfter),
},
},
);
}
export function requireRateLimit(key: string, rule: RateLimitRule) {
const result = checkRateLimit(key, rule);
return result.allowed ? null : rateLimitResponse(result.retryAfter);
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash, randomBytes } from "node:crypto";
export function createPasswordResetToken() {
return randomBytes(32).toString("hex");
}
export function hashPasswordResetToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
+23
View File
@@ -0,0 +1,23 @@
const FALLBACK_CALLBACK_PATH = "/dashboard";
export function safeCallbackPath(value: string | null | undefined) {
if (!value) return FALLBACK_CALLBACK_PATH;
const trimmed = value.trim();
if (
!trimmed.startsWith("/") ||
trimmed.startsWith("//") ||
trimmed.includes("\\") ||
/[\u0000-\u001f\u007f]/.test(trimmed)
) {
return FALLBACK_CALLBACK_PATH;
}
try {
const url = new URL(trimmed, "https://beenvoice.local");
if (url.origin !== "https://beenvoice.local") return FALLBACK_CALLBACK_PATH;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return FALLBACK_CALLBACK_PATH;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { and, eq, ne } from "drizzle-orm";
import { db } from "~/server/db";
import { sessions } from "~/server/db/schema";
export async function revokeUserSessions(userId: string, exceptToken?: string | null) {
const condition = exceptToken
? and(eq(sessions.userId, userId), ne(sessions.token, exceptToken))
: eq(sessions.userId, userId);
await db.delete(sessions).where(condition);
}
+16 -7
View File
@@ -1,6 +1,17 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { isPublicRoute } from "~/lib/public-routes"; import { isPublicRoute } from "~/lib/public-routes";
import { safeCallbackPath } from "~/lib/safe-callback-url";
function hasBetterAuthSessionCookie(request: NextRequest) {
return request.cookies.getAll().some(({ name }) => {
const cookieName = name.replace(/^__Secure-/, "");
return (
cookieName === "better-auth.session_token" ||
cookieName.startsWith("better-auth.session_token.")
);
});
}
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
@@ -18,15 +29,13 @@ export function proxy(request: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// Check for session token in cookies (Better Auth cookie names)
const sessionToken =
request.cookies.get("better-auth.session_token")?.value ??
request.cookies.get("__Secure-better-auth.session_token")?.value;
// If no session token, redirect to sign-in // If no session token, redirect to sign-in
if (!sessionToken) { if (!hasBetterAuthSessionCookie(request)) {
const signInUrl = new URL("/auth/signin", request.url); const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("callbackUrl", request.url); signInUrl.searchParams.set(
"callbackUrl",
safeCallbackPath(`${request.nextUrl.pathname}${request.nextUrl.search}`),
);
return NextResponse.redirect(signInUrl); return NextResponse.redirect(signInUrl);
} }
+5 -22
View File
@@ -7,22 +7,11 @@ import {
getApiKeyDisplayPrefix, getApiKeyDisplayPrefix,
hashApiKey, hashApiKey,
} from "~/server/api/api-keys"; } from "~/server/api/api-keys";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { apiKeys } from "~/server/db/schema"; import { apiKeys } from "~/server/db/schema";
function requireSessionAuth(ctx: { authSource: "session" | "api-key" | "none" }) {
if (ctx.authSource !== "session") {
throw new TRPCError({
code: "FORBIDDEN",
message: "API keys can only be managed from an authenticated session",
});
}
}
export const apiKeysRouter = createTRPCRouter({ export const apiKeysRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => { list: sessionProcedure.query(async ({ ctx }) => {
requireSessionAuth(ctx);
return ctx.db.query.apiKeys.findMany({ return ctx.db.query.apiKeys.findMany({
where: eq(apiKeys.userId, ctx.session.user.id), where: eq(apiKeys.userId, ctx.session.user.id),
columns: { columns: {
@@ -39,7 +28,7 @@ export const apiKeysRouter = createTRPCRouter({
}); });
}), }),
create: protectedProcedure create: sessionProcedure
.input( .input(
z.object({ z.object({
name: z.string().trim().min(1).max(100), name: z.string().trim().min(1).max(100),
@@ -47,8 +36,6 @@ export const apiKeysRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
if (input.expiresAt && input.expiresAt <= new Date()) { if (input.expiresAt && input.expiresAt <= new Date()) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -84,11 +71,9 @@ export const apiKeysRouter = createTRPCRouter({
return { ...apiKey, key }; return { ...apiKey, key };
}), }),
revoke: protectedProcedure revoke: sessionProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
const now = new Date(); const now = new Date();
const [apiKey] = await ctx.db const [apiKey] = await ctx.db
.update(apiKeys) .update(apiKeys)
@@ -108,9 +93,7 @@ export const apiKeysRouter = createTRPCRouter({
return { success: true }; return { success: true };
}), }),
revokeAll: protectedProcedure.mutation(async ({ ctx }) => { revokeAll: sessionProcedure.mutation(async ({ ctx }) => {
requireSessionAuth(ctx);
const now = new Date(); const now = new Date();
await ctx.db await ctx.db
.update(apiKeys) .update(apiKeys)
+30 -1
View File
@@ -1,4 +1,4 @@
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq, gte, lt } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { clients, invoices } from "~/server/db/schema"; import { clients, invoices } from "~/server/db/schema";
@@ -172,6 +172,7 @@ export const dashboardRouter = createTRPCRouter({
userInvoices, userInvoices,
userClientsCount, userClientsCount,
recentInvoices, recentInvoices,
monthInvoices,
currentDraft, currentDraft,
] = await Promise.all([ ] = await Promise.all([
ctx.db.query.invoices.findMany({ ctx.db.query.invoices.findMany({
@@ -199,6 +200,33 @@ export const dashboardRouter = createTRPCRouter({
}, },
}, },
}), }),
ctx.db.query.invoices.findMany({
where: and(
eq(invoices.createdById, userId),
gte(invoices.issueDate, new Date(now.getFullYear(), now.getMonth(), 1)),
lt(invoices.issueDate, new Date(now.getFullYear(), now.getMonth() + 1, 1)),
),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
columns: {
id: true,
invoicePrefix: true,
invoiceNumber: true,
totalAmount: true,
status: true,
dueDate: true,
issueDate: true,
currency: true,
},
with: {
client: {
columns: { name: true },
},
},
}),
ctx.db.query.invoices.findFirst({ ctx.db.query.invoices.findFirst({
where: and( where: and(
eq(invoices.createdById, userId), eq(invoices.createdById, userId),
@@ -227,6 +255,7 @@ export const dashboardRouter = createTRPCRouter({
...metrics, ...metrics,
totalClients: userClientsCount, totalClients: userClientsCount,
recentInvoices, recentInvoices,
monthInvoices,
currentDraft: currentDraft currentDraft: currentDraft
? { ? {
id: currentDraft.id, id: currentDraft.id,
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { Resend } from "resend"; import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { invoices, platformSettings } from "~/server/db/schema"; import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { env } from "~/env"; import { env } from "~/env";
@@ -36,7 +36,7 @@ function normalizeEmailNoteHtml(value: string) {
} }
export const emailRouter = createTRPCRouter({ export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure sendInvoice: sessionProcedure
.input( .input(
z.object({ z.object({
invoiceId: z.string(), invoiceId: z.string(),
+10 -5
View File
@@ -1,6 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm"; import { and, desc, eq, inArray } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
sessionProcedure,
} from "../trpc";
import { import {
invoices, invoices,
invoiceItems, invoiceItems,
@@ -754,7 +759,7 @@ export const invoicesRouter = createTRPCRouter({
return { success: true, deleted: ownedIds.length }; return { success: true, deleted: ownedIds.length };
}), }),
bulkImport: protectedProcedure bulkImport: sessionProcedure
.input(bulkImportSchema) .input(bulkImportSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
@@ -998,7 +1003,7 @@ export const invoicesRouter = createTRPCRouter({
// ── Public token (shareable link) ────────────────────────────────────────── // ── Public token (shareable link) ──────────────────────────────────────────
generatePublicToken: protectedProcedure generatePublicToken: sessionProcedure
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() })) .input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
@@ -1018,7 +1023,7 @@ export const invoicesRouter = createTRPCRouter({
return { token, expiresAt }; return { token, expiresAt };
}), }),
revokePublicToken: protectedProcedure revokePublicToken: sessionProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
@@ -1060,7 +1065,7 @@ export const invoicesRouter = createTRPCRouter({
// ── Send reminder ────────────────────────────────────────────────────────── // ── Send reminder ──────────────────────────────────────────────────────────
sendReminder: protectedProcedure sendReminder: sessionProcedure
.input(z.object({ id: z.string(), customMessage: z.string().optional() })) .input(z.object({ id: z.string(), customMessage: z.string().optional() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
+8 -4
View File
@@ -7,6 +7,7 @@ import {
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
publicProcedure, publicProcedure,
sessionProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { requireAdmin } from "~/server/api/require-admin"; import { requireAdmin } from "~/server/api/require-admin";
import { import {
@@ -32,6 +33,7 @@ import {
pdfTemplateSchema, pdfTemplateSchema,
type ColorMode, type ColorMode,
} from "~/lib/branding"; } from "~/lib/branding";
import { revokeUserSessions } from "~/lib/session-security";
function resolveBusinessId( function resolveBusinessId(
refs: { businessName?: string; businessNickname?: string }, refs: { businessName?: string; businessNickname?: string },
@@ -512,7 +514,7 @@ export const settingsRouter = createTRPCRouter({
}), }),
// Change user password // Change user password
changePassword: protectedProcedure changePassword: sessionProcedure
.input( .input(
z z
.object({ .object({
@@ -595,11 +597,13 @@ export const settingsRouter = createTRPCRouter({
} }
}); });
await revokeUserSessions(userId, ctx.session.session?.token);
return { success: true }; return { success: true };
}), }),
// Export user data (backup) // Export user data (backup)
exportData: protectedProcedure.query(async ({ ctx }) => { exportData: sessionProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const user = await ctx.db.query.users.findFirst({ const user = await ctx.db.query.users.findFirst({
@@ -855,7 +859,7 @@ export const settingsRouter = createTRPCRouter({
}), }),
// Import user data (restore) // Import user data (restore)
importData: protectedProcedure importData: sessionProcedure
.input(BackupDataSchema) .input(BackupDataSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
@@ -1168,7 +1172,7 @@ export const settingsRouter = createTRPCRouter({
}), }),
// Delete all user data (for account deletion) // Delete all user data (for account deletion)
deleteAllData: protectedProcedure deleteAllData: sessionProcedure
.input( .input(
z.object({ z.object({
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", { confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
+6 -3
View File
@@ -209,7 +209,10 @@ export const timeEntriesRouter = createTRPCRouter({
), ),
with: { with: {
client: true, client: true,
invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } }, invoice: {
columns: { id: true, invoiceNumber: true, invoicePrefix: true },
with: { business: { columns: { id: true, name: true } } },
},
}, },
}); });
return entry ?? null; return entry ?? null;
@@ -239,7 +242,7 @@ export const timeEntriesRouter = createTRPCRouter({
}); });
} }
const clientId = input.clientId?.trim() ?? null; const clientId = input.clientId?.trim() || null;
let clientRecord: { defaultHourlyRate: number | null } | null = null; let clientRecord: { defaultHourlyRate: number | null } | null = null;
if (clientId) { if (clientId) {
const found = await ctx.db.query.clients.findFirst({ const found = await ctx.db.query.clients.findFirst({
@@ -511,7 +514,7 @@ export const timeEntriesRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(createSchema) .input(createSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const clientId = input.clientId?.trim() ?? null; const clientId = input.clientId?.trim() || null;
if (clientId) { if (clientId) {
const client = await ctx.db.query.clients.findFirst({ const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)), where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
+45 -5
View File
@@ -12,7 +12,11 @@ import superjson from "superjson";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { auth } from "~/lib/auth"; import { auth } from "~/lib/auth";
import { hasSessionCookie } from "~/lib/auth-server"; import {
hasSessionCookie,
headersWithAuthCookieFallback,
} from "~/lib/auth-server";
import { checkRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys"; import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
@@ -29,7 +33,8 @@ import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
* @see https://trpc.io/docs/server/context * @see https://trpc.io/docs/server/context
*/ */
export const createTRPCContext = async (opts: { headers: Headers }) => { export const createTRPCContext = async (opts: { headers: Headers }) => {
const bearerToken = getBearerToken(opts.headers); const headers = headersWithAuthCookieFallback(opts.headers);
const bearerToken = getBearerToken(headers);
if (bearerToken) { if (bearerToken) {
const apiKeyAuth = await getUserForApiKey(db, bearerToken); const apiKeyAuth = await getUserForApiKey(db, bearerToken);
@@ -44,23 +49,25 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
authSource: "api-key" as const, authSource: "api-key" as const,
apiKeyId: apiKeyAuth.apiKeyId, apiKeyId: apiKeyAuth.apiKeyId,
...opts, ...opts,
headers,
}; };
} }
} }
if (!hasSessionCookie(opts.headers)) { if (!hasSessionCookie(headers)) {
return { return {
db, db,
session: null, session: null,
authSource: "none" as const, authSource: "none" as const,
apiKeyId: null, apiKeyId: null,
...opts, ...opts,
headers,
}; };
} }
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: opts.headers, headers,
}); });
return { return {
@@ -69,6 +76,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
authSource: session?.user ? ("session" as const) : ("none" as const), authSource: session?.user ? ("session" as const) : ("none" as const),
apiKeyId: null, apiKeyId: null,
...opts, ...opts,
headers,
}; };
} catch (error) { } catch (error) {
console.error("[tRPC] Failed to resolve session:", error); console.error("[tRPC] Failed to resolve session:", error);
@@ -79,6 +87,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
authSource: "none" as const, authSource: "none" as const,
apiKeyId: null, apiKeyId: null,
...opts, ...opts,
headers,
}; };
} }
}; };
@@ -143,6 +152,24 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
return result; return result;
}); });
const apiKeyRateLimitMiddleware = t.middleware(({ ctx, next }) => {
if (ctx.authSource === "api-key" && ctx.apiKeyId) {
const result = checkRateLimit(`trpc:api-key:${ctx.apiKeyId}`, {
windowMs: 60 * 1000,
max: 120,
});
if (!result.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "API key rate limit exceeded. Please try again later.",
});
}
}
return next();
});
/** /**
* Public (unauthenticated) procedure * Public (unauthenticated) procedure
* *
@@ -150,7 +177,9 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
* guarantee that a user querying is authorized, but you can still access user session data if they * guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in. * are logged in.
*/ */
export const publicProcedure = t.procedure.use(timingMiddleware); export const publicProcedure = t.procedure
.use(timingMiddleware)
.use(apiKeyRateLimitMiddleware);
/** /**
* Protected (authenticated) procedure * Protected (authenticated) procedure
@@ -173,3 +202,14 @@ export const protectedProcedure = t.procedure
}, },
}); });
}); });
export const sessionProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.authSource !== "session") {
throw new TRPCError({
code: "FORBIDDEN",
message: "This action requires an authenticated browser or app session",
});
}
return next();
});