2 Commits

Author SHA1 Message Date
soconnor ddc2b42672 Refactor invoice data table and templates page for improved readability and functionality
- Cleaned up imports and formatted code for better readability in invoices-data-table.tsx.
- Enhanced invoice interface definitions for clarity.
- Improved toast messages for bulk delete and update actions.
- Refactored date formatting and status type retrieval for better readability.
- Simplified template management in templates page, extracting TemplateList component.
- Added registration toggle based on environment variable DISABLE_SIGNUPS.
- Updated navbar to conditionally render registration link based on allowRegistration prop.
- Enhanced error handling and validation in expenses and settings routers.
- Improved PDF export footer handling.
- Updated TRPC react integration for cleaner type imports.
2026-04-29 22:49:07 -04:00
soconnor dbb739b060 refactor: update SendEmailPage layout and remove SendEmailDialog component 2026-04-28 01:30:38 -04:00
22 changed files with 918 additions and 940 deletions
+38 -4
View File
@@ -44,22 +44,26 @@ A modern, professional invoicing application built for freelancers and small bus
### Quick Start ### Quick Start
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone https://github.com/yourusername/beenvoice.git git clone https://github.com/yourusername/beenvoice.git
cd beenvoice cd beenvoice
``` ```
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
bun install bun install
``` ```
3. **Set up environment variables** 3. **Set up environment variables**
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
``` ```
Edit `.env.local` and add your configuration: Edit `.env.local` and add your configuration:
```env ```env
# Database # Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice" DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
@@ -78,17 +82,20 @@ A modern, professional invoicing application built for freelancers and small bus
RESEND_DOMAIN="yourdomain.com" RESEND_DOMAIN="yourdomain.com"
``` ```
4. **Start the database** 4. **Start the development database**
```bash ```bash
docker-compose up -d docker compose -f docker-compose.dev.yml up -d db
``` ```
5. **Push the database schema** 5. **Push the database schema**
```bash ```bash
bun run db:push bun run db:push
``` ```
6. **Start the development server** 6. **Start the development server**
```bash ```bash
bun run dev bun run dev
``` ```
@@ -123,7 +130,8 @@ beenvoice/
├── drizzle/ # Database migrations ├── drizzle/ # Database migrations
├── public/ # Static assets ├── public/ # Static assets
├── docs/ # Documentation ├── docs/ # Documentation
── docker-compose.yml # Local PostgreSQL setup ── docker-compose.yml # Deployment compose stack
└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL
``` ```
## 🎯 Usage ## 🎯 Usage
@@ -155,12 +163,14 @@ beenvoice/
### Features Overview ### Features Overview
#### Client Management #### Client Management
- Create and edit client profiles - Create and edit client profiles
- Store contact information and addresses - Store contact information and addresses
- Set default hourly rates per client - Set default hourly rates per client
- Search and filter client list - Search and filter client list
#### Invoice Creation #### Invoice Creation
- Select from existing clients and business profiles - Select from existing clients and business profiles
- Add multiple line items with drag-and-drop reordering - Add multiple line items with drag-and-drop reordering
- Set custom rates per item - Set custom rates per item
@@ -169,12 +179,14 @@ beenvoice/
- Professional invoice formatting - Professional invoice formatting
#### Invoice Delivery #### Invoice Delivery
- Send invoices via email directly from the app - Send invoices via email directly from the app
- Rich text email composer with preview - Rich text email composer with preview
- Resend and re-deliver sent invoices - Resend and re-deliver sent invoices
- Track invoice status: Draft → Sent → Paid (+ Overdue) - Track invoice status: Draft → Sent → Paid (+ Overdue)
#### User Interface #### User Interface
- Clean, modern design - Clean, modern design
- Fully responsive — desktop, tablet, and mobile - Fully responsive — desktop, tablet, and mobile
- Intuitive navigation with breadcrumbs - Intuitive navigation with breadcrumbs
@@ -198,7 +210,8 @@ bun run db:studio # Open Drizzle Studio
bun run db:generate # Generate new migration bun run db:generate # Generate new migration
# Docker # Docker
bun run docker:up # Start local PostgreSQL via Docker bun run docker:up # Start deployment compose stack
bun run docker:dev:up # Start development compose stack with exposed PostgreSQL
bun run docker:down # Stop Docker services bun run docker:down # Stop Docker services
# Code Quality # Code Quality
@@ -208,6 +221,24 @@ bun run format:write # Format code with Prettier
bun run typecheck # Run TypeScript type checking bun run typecheck # Run TypeScript type checking
``` ```
### Docker Compose
Use the base compose file for deployment. It keeps PostgreSQL internal to the
compose network:
```bash
docker compose up -d
```
For local development, use the dev compose file to expose PostgreSQL on
`${POSTGRES_PORT:-5432}`:
```bash
docker compose -f docker-compose.dev.yml up -d
```
Set `DISABLE_SIGNUPS=true` to block new email/password account registration.
### Database Schema ### Database Schema
The application uses the following core tables: The application uses the following core tables:
@@ -243,6 +274,7 @@ The app uses Tailwind CSS v4 with a custom design system:
### Branding ### Branding
Update the logo and colors in: Update the logo and colors in:
- `src/components/logo.tsx` - Main logo component - `src/components/logo.tsx` - Main logo component
- `src/styles/globals.css` - Color variables - `src/styles/globals.css` - Color variables
- `src/app/layout.tsx` - Font configuration - `src/app/layout.tsx` - Font configuration
@@ -252,6 +284,7 @@ Update the logo and colors in:
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.). You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
1. **Build the application:** 1. **Build the application:**
```bash ```bash
bun run build bun run build
``` ```
@@ -259,6 +292,7 @@ You can deploy this application to any platform that supports Next.js and Postgr
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production) 2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
3. **Run database migrations:** 3. **Run database migrations:**
```bash ```bash
bun run db:push bun run db:push
``` ```
+21
View File
@@ -0,0 +1,21 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_dev_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
volumes:
beenvoice_dev_pg_data:
+3 -1
View File
@@ -15,6 +15,7 @@ services:
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
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:-false}
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:-}
@@ -35,7 +36,8 @@ services:
volumes: volumes:
- beenvoice_pg_data:/var/lib/postgresql/data - beenvoice_pg_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
+2
View File
@@ -12,7 +12,9 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:clone": "./scripts/clone-local.sh", "db:clone": "./scripts/clone-local.sh",
"docker:up": "colima start && docker compose up -d", "docker:up": "colima start && docker compose up -d",
"docker:dev:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
"docker:down": "docker compose down && colima stop", "docker:down": "docker compose down && colima stop",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"deploy": "drizzle-kit push && next build", "deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
+52 -14
View File
@@ -2,59 +2,97 @@ import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
const registerSchema = z.object({ const registerSchema = z.object({
firstName: z.string().min(1, "First name is required"), firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"), lastName: z.string().trim().min(1, "Last name is required"),
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"), password: z.string().min(8, "Password must be at least 8 characters"),
}); });
const fieldLabels: Record<string, string> = {
firstName: "First name",
lastName: "Last name",
email: "Email address",
password: "Password",
};
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() as z.infer<typeof registerSchema>; if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json(
{ error: "New account registration is currently disabled" },
{ status: 403 },
);
}
const body = (await request.json()) as unknown;
const { firstName, lastName, email, password } = registerSchema.parse(body); const { firstName, lastName, email, password } = registerSchema.parse(body);
const normalizedEmail = email.toLowerCase();
// Check if user already exists // Check if user already exists
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.email, email), where: eq(users.email, normalizedEmail),
}); });
if (existingUser) { if (existingUser) {
return NextResponse.json( return NextResponse.json(
{ error: "User with this email already exists" }, { error: "User with this email already exists" },
{ status: 400 } { status: 400 },
); );
} }
// Hash password // Hash password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Create user await db.transaction(async (tx) => {
await db.insert(users).values({ const [user] = await tx
.insert(users)
.values({
name: `${firstName} ${lastName}`, name: `${firstName} ${lastName}`,
email, email: normalizedEmail,
password: hashedPassword, password: hashedPassword,
})
.returning({ id: users.id });
if (!user) {
throw new Error("Failed to create user");
}
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}); });
return NextResponse.json( return NextResponse.json(
{ message: "User created successfully" }, { message: "User created successfully" },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
const issue = error.errors[0];
const field = issue?.path[0];
const fallback =
typeof field === "string"
? `${fieldLabels[field] ?? field} is required`
: "Please check the registration form";
return NextResponse.json( return NextResponse.json(
{ error: error.errors[0]?.message ?? "Validation error" }, { error: issue?.message === "Required" ? fallback : issue?.message },
{ status: 400 } { status: 400 },
); );
} }
console.error("Registration error:", error); console.error("Registration error:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 } { status: 500 },
); );
} }
} }
+28 -3
View File
@@ -2,7 +2,7 @@ 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 { db } from "~/server/db"; import { db } from "~/server/db";
import { 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 {
@@ -47,8 +47,8 @@ export async function POST(request: NextRequest) {
// Hash the new password // Hash the new password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Update user with new password and clear reset token await db.transaction(async (tx) => {
await db await tx
.update(users) .update(users)
.set({ .set({
password: hashedPassword, password: hashedPassword,
@@ -57,6 +57,31 @@ export async function POST(request: NextRequest) {
}) })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, user.id),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}
});
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
+2 -1
View File
@@ -28,7 +28,8 @@ function RegisterForm() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name: `${firstName} ${lastName}`, firstName,
lastName,
email, email,
password, password,
}), }),
+3 -282
View File
@@ -1,290 +1,11 @@
"use client"; import { Suspense } from "react";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env"; import { env } from "~/env";
import { import { SignInForm } from "./signin-form";
Mail,
Lock,
ArrowRight,
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
function SignInForm() {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
// The signIn.sso method will automatically redirect to the SSO provider
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */}
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your
<span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
invoices that get you paid faster.
</p>
</div>
</div>
<div className="grid gap-6">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
</div>
</div>
</div>
</div>
</div>
{/* Sign In Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
{authentikEnabled && (
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="relative h-11 w-full rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password"
className="text-primary text-sm hover:underline"
>
Forgot password?
</a>
</div>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Signing in...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Sign In</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPage() { export default function SignInPage() {
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<SignInForm /> <SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
</Suspense> </Suspense>
); );
} }
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env";
import {
Mail,
Lock,
ArrowRight,
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
interface SignInFormProps {
allowRegistration: boolean;
}
export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const signupDisabled = searchParams.get("signup") === "disabled";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
// The signIn.sso method will automatically redirect to the SSO provider
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */}
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your
<span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
invoices that get you paid faster.
</p>
</div>
</div>
<div className="grid gap-6">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
</div>
</div>
</div>
</div>
</div>
{/* Sign In Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
{signupDisabled && (
<div className="border-border bg-muted/50 text-muted-foreground rounded-lg border px-3 py-2 text-sm">
New account registration is currently disabled.
</div>
)}
{authentikEnabled && (
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="relative h-11 w-full rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password"
className="text-primary text-sm hover:underline"
>
Forgot password?
</a>
</div>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Signing in...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Sign In</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
{allowRegistration && (
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
)}
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPageClient() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SignInForm allowRegistration />
</Suspense>
);
}
@@ -292,7 +292,7 @@ export default function SendEmailPage() {
if (!invoice) { if (!invoice) {
return ( return (
<div className="container mx-auto max-w-4xl p-6"> <div className="page-enter space-y-6">
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription> <AlertDescription>Invoice not found.</AlertDescription>
@@ -302,7 +302,7 @@ export default function SendEmailPage() {
} }
return ( return (
<div className="container mx-auto max-w-6xl space-y-6 pb-32"> <div className="page-enter space-y-6 pb-32">
<PageHeader <PageHeader
title={`Send Invoice ${invoice.invoiceNumber}`} title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat( description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
@@ -23,7 +23,15 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { Eye, Edit, Trash2, FileText, CheckCircle, Send, ChevronDown } from "lucide-react"; import {
Eye,
Edit,
Trash2,
FileText,
CheckCircle,
Send,
ChevronDown,
} from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
@@ -45,11 +53,28 @@ interface Invoice {
createdById: string; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;
client?: { id: string; name: string; email: string | null; phone: string | null } | null; client?: {
business?: { id: string; name: string; email: string | null; phone: string | null } | null; id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
business?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
items?: Array<{ items?: Array<{
id: string; invoiceId: string; date: Date; description: string; id: string;
hours: number; rate: number; amount: number; position: number; createdAt: Date; invoiceId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position: number;
createdAt: Date;
}> | null; }> | null;
} }
@@ -58,10 +83,17 @@ interface InvoicesDataTableProps {
} }
const getStatusType = (invoice: Invoice): StatusType => const getStatusType = (invoice: Invoice): StatusType =>
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType; getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) as StatusType;
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date)); new Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(date));
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const router = useRouter(); const router = useRouter();
@@ -84,7 +116,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkDelete = api.invoices.bulkDelete.useMutation({ const bulkDelete = api.invoices.bulkDelete.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`); toast.success(
`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
setBulkDeleteDialogOpen(false); setBulkDeleteDialogOpen(false);
setPendingBulkDelete([]); setPendingBulkDelete([]);
@@ -94,7 +128,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({ const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`); toast.success(
`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
}, },
onError: (e) => toast.error(e.message ?? "Failed to update invoices"), onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
@@ -105,7 +141,10 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all" aria-label="Select all"
data-action-button="true" data-action-button="true"
@@ -124,7 +163,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
accessorKey: "client.name", accessorKey: "client.name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Client" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Client" />
),
cell: ({ row }) => { cell: ({ row }) => {
const invoice = row.original; const invoice = row.original;
return ( return (
@@ -133,10 +174,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<FileText className="text-primary h-4 w-4" /> <FileText className="text-primary h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p> <p className="truncate font-medium">
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p> {invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
<div className="mt-1 flex items-center gap-2 sm:hidden"> <div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge status={getStatusType(invoice)} className="text-xs" /> <StatusBadge
status={getStatusType(invoice)}
className="text-xs"
/>
<span className="text-foreground text-xs font-semibold"> <span className="text-foreground text-xs font-semibold">
{formatCurrency(invoice.totalAmount, invoice.currency)} {formatCurrency(invoice.totalAmount, invoice.currency)}
</span> </span>
@@ -148,38 +196,59 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
accessorKey: "issueDate", accessorKey: "issueDate",
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Date" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p> <p className="truncate text-sm">
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p> {formatDate(row.getValue("issueDate"))}
</p>
<p className="text-muted-foreground truncate text-xs">
Due {formatDate(new Date(row.original.dueDate))}
</p>
</div> </div>
), ),
}, },
{ {
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<StatusBadge <StatusBadge
status={getStatusType(row.original)} status={getStatusType(row.original)}
className={getStatusType(row.original) === "sent" ? "status-pending" : ""} className={
getStatusType(row.original) === "sent" ? "status-pending" : ""
}
/> />
), ),
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)), filterFn: (row, _id, value: string[]) =>
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, value.includes(getStatusType(row.original)),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
accessorKey: "totalAmount", accessorKey: "totalAmount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-right"> <div className="text-right">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)} {formatCurrency(row.getValue("totalAmount"), row.original.currency)}
</p>
<p className="text-muted-foreground text-xs">
{row.original.items?.length ?? 0} items
</p> </p>
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
</div> </div>
), ),
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
id: "actions", id: "actions",
@@ -188,19 +257,34 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/invoices/${invoice.id}`}> <Link href={`/dashboard/invoices/${invoice.id}`}>
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true"> <Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Eye className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true"> <Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Button <Button
variant="ghost" size="sm" variant="ghost"
size="sm"
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0" className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }} onClick={(e) => {
e.stopPropagation();
setInvoiceToDelete(invoice);
setDeleteDialogOpen(true);
}}
data-action-button="true" data-action-button="true"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -237,12 +321,18 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchKey="invoiceNumber" searchKey="invoiceNumber"
searchPlaceholder="Search invoices..." searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns} filterableColumns={filterableColumns}
onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`)} onRowClick={(invoice) =>
router.push(`/dashboard/invoices/${invoice.id}`)
}
selectionActions={(selected, clear) => ( selectionActions={(selected, clear) => (
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={bulkUpdateStatus.isPending}> <Button
variant="outline"
size="sm"
disabled={bulkUpdateStatus.isPending}
>
<Send className="mr-1.5 h-3.5 w-3.5" /> <Send className="mr-1.5 h-3.5 w-3.5" />
Mark as Mark as
<ChevronDown className="ml-1.5 h-3.5 w-3.5" /> <ChevronDown className="ml-1.5 h-3.5 w-3.5" />
@@ -306,16 +396,24 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<DialogDescription> <DialogDescription>
Are you sure you want to delete invoice{" "} Are you sure you want to delete invoice{" "}
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "} <strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone. <strong>{invoiceToDelete?.client?.name}</strong>? This action
cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}> <Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteInvoice.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })} onClick={() =>
invoiceToDelete &&
deleteInvoice.mutate({ id: invoiceToDelete.id })
}
disabled={deleteInvoice.isPending} disabled={deleteInvoice.isPending}
> >
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"} {deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
@@ -325,25 +423,40 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</Dialog> </Dialog>
{/* Bulk delete dialog */} {/* Bulk delete dialog */}
<Dialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}> <Dialog
open={bulkDeleteDialogOpen}
onOpenChange={setBulkDeleteDialogOpen}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete {pendingBulkDelete.length} Invoice{pendingBulkDelete.length !== 1 ? "s" : ""}</DialogTitle> <DialogTitle>
Delete {pendingBulkDelete.length} Invoice
{pendingBulkDelete.length !== 1 ? "s" : ""}
</DialogTitle>
<DialogDescription> <DialogDescription>
This will permanently delete {pendingBulkDelete.length} invoice{pendingBulkDelete.length !== 1 ? "s" : ""}. This will permanently delete {pendingBulkDelete.length} invoice
This action cannot be undone. {pendingBulkDelete.length !== 1 ? "s" : ""}. This action cannot be
undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteDialogOpen(false)} disabled={bulkDelete.isPending}> <Button
variant="outline"
onClick={() => setBulkDeleteDialogOpen(false)}
disabled={bulkDelete.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })} onClick={() =>
bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })
}
disabled={bulkDelete.isPending} disabled={bulkDelete.isPending}
> >
{bulkDelete.isPending ? "Deleting..." : `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`} {bulkDelete.isPending
? "Deleting..."
: `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+185 -74
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { api } from "~/trpc/react"; import { api, type RouterOutputs } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@@ -18,12 +18,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "~/components/ui/tabs";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react"; import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
@@ -34,87 +29,81 @@ interface TemplateForm {
isDefault: boolean; isDefault: boolean;
} }
const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false }; const defaultForm: TemplateForm = {
name: "",
type: "notes",
content: "",
isDefault: false,
};
export default function TemplatesPage() { type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils(); interface TemplateListProps {
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery(); items: InvoiceTemplate[];
type: "notes" | "terms";
isLoading: boolean;
onCreate: (type: "notes" | "terms") => void;
onEdit: (template: InvoiceTemplate) => void;
onDelete: (id: string) => void;
}
const create = api.invoiceTemplates.create.useMutation({ function TemplateList({
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); }, items,
onError: (e) => toast.error(e.message), type,
}); isLoading,
const update = api.invoiceTemplates.update.useMutation({ onCreate,
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); }, onEdit,
onError: (e) => toast.error(e.message), onDelete,
}); }: TemplateListProps) {
const del = api.invoiceTemplates.delete.useMutation({ return (
onSuccess: () => { toast.success("Template deleted"); void utils.invoiceTemplates.getAll.invalidate(); setDeleteId(null); },
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: typeof templates[0]) => {
setEditId(t.id);
setForm({ name: t.name, type: t.type as "notes" | "terms", content: t.content, isDefault: t.isDefault });
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) { toast.error("Name is required"); return; }
if (!form.content.trim()) { toast.error("Content is required"); return; }
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={() => handleOpen(type)}> <Button size="sm" onClick={() => onCreate(type)}>
<Plus className="mr-1.5 h-3.5 w-3.5" /> New {type === "notes" ? "Notes" : "Terms"} Template <Plus className="mr-1.5 h-3.5 w-3.5" /> New{" "}
{type === "notes" ? "Notes" : "Terms"} Template
</Button> </Button>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">Loading</div> <div className="text-muted-foreground py-8 text-center text-sm">
Loading...
</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-sm">
No {type} templates yet. No {type} templates yet.
</div> </div>
) : ( ) : (
items.map((t) => ( items.map((template) => (
<Card key={t.id}> <Card key={template.id}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{t.name}</p> <p className="font-medium">{template.name}</p>
{t.isDefault && ( {template.isDefault && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<Star className="mr-1 h-3 w-3" /> Default <Star className="mr-1 h-3 w-3" /> Default
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap"> <p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
{t.content} {template.content}
</p> </p>
</div> </div>
<div className="flex flex-shrink-0 gap-1"> <div className="flex flex-shrink-0 gap-1">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(t)}> <Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onEdit(template)}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(t.id)}> <Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => onDelete(template.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -125,6 +114,77 @@ export default function TemplatesPage() {
)} )}
</div> </div>
); );
}
export default function TemplatesPage() {
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils();
const { data: templates = [], isLoading } =
api.invoiceTemplates.getAll.useQuery();
const create = api.invoiceTemplates.create.useMutation({
onSuccess: () => {
toast.success("Template created");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const update = api.invoiceTemplates.update.useMutation({
onSuccess: () => {
toast.success("Template updated");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setEditId(null);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const del = api.invoiceTemplates.delete.useMutation({
onSuccess: () => {
toast.success("Template deleted");
void utils.invoiceTemplates.getAll.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: InvoiceTemplate) => {
setEditId(t.id);
setForm({
name: t.name,
type: t.type as "notes" | "terms",
content: t.content,
isDefault: t.isDefault,
});
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) {
toast.error("Name is required");
return;
}
if (!form.content.trim()) {
toast.error("Content is required");
return;
}
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
@@ -137,17 +197,33 @@ export default function TemplatesPage() {
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}> <Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes"> <TabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes ({notesTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="terms"> <TabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms ({termsTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length})
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="notes" className="mt-4"> <TabsContent value="notes" className="mt-4">
<TemplateList items={notesTemplates} type="notes" /> <TemplateList
items={notesTemplates}
type="notes"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
<TabsContent value="terms" className="mt-4"> <TabsContent value="terms" className="mt-4">
<TemplateList items={termsTemplates} type="terms" /> <TemplateList
items={termsTemplates}
type="terms"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -155,16 +231,29 @@ export default function TemplatesPage() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{editId ? "Edit Template" : "New Template"}</DialogTitle> <DialogTitle>
{editId ? "Edit Template" : "New Template"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Name *</Label> <Label>Name *</Label>
<Input value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" /> <Input
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="e.g. Standard Payment Terms"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
<Tabs value={form.type} onValueChange={(v) => setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}> <Tabs
value={form.type}
onValueChange={(v) =>
setForm((p) => ({ ...p, type: v as "notes" | "terms" }))
}
>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">Notes</TabsTrigger> <TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="terms">Terms</TabsTrigger> <TabsTrigger value="terms">Terms</TabsTrigger>
@@ -175,20 +264,36 @@ export default function TemplatesPage() {
<Label>Content *</Label> <Label>Content *</Label>
<Textarea <Textarea
value={form.content} value={form.content}
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} onChange={(e) =>
setForm((p) => ({ ...p, content: e.target.value }))
}
placeholder="Template content…" placeholder="Template content…"
className="min-h-[120px]" className="min-h-[120px]"
/> />
</div> </div>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.isDefault} onCheckedChange={(v) => setForm((p) => ({ ...p, isDefault: !!v }))} /> <Checkbox
checked={form.isDefault}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, isDefault: !!v }))
}
/>
<span className="text-sm">Set as default for {form.type}</span> <span className="text-sm">Set as default for {form.type}</span>
</label> </label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setOpen(false)}>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}> Cancel
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Create"} </Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -202,8 +307,14 @@ export default function TemplatesPage() {
<DialogDescription>This action cannot be undone.</DialogDescription> <DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button> <Button variant="outline" onClick={() => setDeleteId(null)}>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}> Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"} {del.isPending ? "Deleting…" : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
+18 -2
View File
@@ -13,8 +13,11 @@ import {
Rocket, Rocket,
} from "lucide-react"; } from "lucide-react";
import { brand } from "~/lib/branding"; import { brand } from "~/lib/branding";
import { env } from "~/env";
export default function HomePage() { export default function HomePage() {
const allowRegistration = env.DISABLE_SIGNUPS !== true;
return ( return (
<div className="relative min-h-screen overflow-x-hidden"> <div className="relative min-h-screen overflow-x-hidden">
<AuthRedirect /> <AuthRedirect />
@@ -54,11 +57,17 @@ export default function HomePage() {
Sign In Sign In
</Button> </Button>
</Link> </Link>
{allowRegistration && (
<Link href="/auth/register"> <Link href="/auth/register">
<Button size="sm" variant="default" className="rounded-xl px-6"> <Button
size="sm"
variant="default"
className="rounded-xl px-6"
>
Get Started Get Started
</Button> </Button>
</Link> </Link>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -83,6 +92,7 @@ export default function HomePage() {
</p> </p>
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center"> <div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
{allowRegistration && (
<Link href="/auth/register"> <Link href="/auth/register">
<Button <Button
size="lg" size="lg"
@@ -92,6 +102,7 @@ export default function HomePage() {
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5" />
</Button> </Button>
</Link> </Link>
)}
<a href="#features"> <a href="#features">
<Button <Button
variant="outline" variant="outline"
@@ -240,11 +251,16 @@ export default function HomePage() {
))} ))}
</div> </div>
{allowRegistration && (
<Link href="/auth/register" className="block"> <Link href="/auth/register" className="block">
<Button size="lg" className="h-12 w-full rounded-xl text-lg"> <Button
size="lg"
className="h-12 w-full rounded-xl text-lg"
>
Get Started Get Started
</Button> </Button>
</Link> </Link>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
-455
View File
@@ -1,455 +0,0 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { EmailComposer } from "./email-composer";
import { EmailPreview } from "./email-preview";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import {
Send,
Loader2,
Eye,
Edit3,
CheckCircle,
AlertTriangle,
Mail,
} from "lucide-react";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface SendEmailDialogProps {
invoiceId: string;
trigger: React.ReactNode;
invoice?: {
id: string;
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
taxRate: number;
currency?: string | null;
client?: {
name: string;
email: string | null;
};
business?: {
name: string;
email: string | null;
};
items?: Array<{
id: string;
date?: Date;
description?: string;
hours: number;
rate: number;
amount?: number;
}>;
};
onEmailSent?: () => void;
}
export function SendEmailDialog({
invoiceId,
trigger,
invoice,
onEmailSent,
}: SendEmailDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState("compose");
const [isSending, setIsSending] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
// Email content state
const [subject, setSubject] = useState(() =>
invoice
? `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`
: "Invoice from Your Business",
);
const [ccEmail, setCcEmail] = useState("");
const [bccEmail, setBccEmail] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [emailContent, setEmailContent] = useState(() => {
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
if (!invoice) return "";
const businessName = invoice.business?.name ?? "Your Business";
const issueDate = formatDate(invoice.issueDate);
// Calculate total from items
const subtotal =
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) ??
0;
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
return `<p>${getTimeOfDayGreeting()},</p>
<p>I hope this email finds you well. Please find attached invoice <strong>${invoice.invoiceNumber}</strong> dated ${issueDate}.</p>
<p>The invoice details are as follows:</p>
<ul>
<li><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</li>
<li><strong>Issue Date:</strong> ${issueDate}</li>
<li><strong>Amount Due:</strong> ${new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)}</li>
</ul>
<p>Please let me know if you have any questions or need any clarification regarding this invoice. I appreciate your prompt attention to this matter.</p>
<p>Thank you for your business!</p>
<p>Best regards,<br><strong>${businessName}</strong></p>`;
});
// Get utils for cache invalidation
const utils = api.useUtils();
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
toast.success("Email sent successfully!", {
description: data.message,
duration: 5000,
});
// Reset state and close dialog
setIsOpen(false);
setActiveTab("compose");
setIsSending(false);
setIsConfirming(false);
// Refresh invoice data
void utils.invoices.getById.invalidate({ id: invoiceId });
// Callback for parent component
onEmailSent?.();
},
onError: (error) => {
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = error.message;
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
setIsSending(false);
setIsConfirming(false);
},
});
const handleSendEmail = async () => {
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
toast.error("No email address", {
description: "This client doesn't have an email address on file.",
});
return;
}
if (!subject.trim()) {
toast.error("Subject required", {
description: "Please enter an email subject before sending.",
});
return;
}
if (!emailContent.trim()) {
toast.error("Message required", {
description: "Please enter an email message before sending.",
});
return;
}
setIsSending(true);
try {
// Use the enhanced API with custom subject and content
await sendEmailMutation.mutateAsync({
invoiceId,
customSubject: subject,
customContent: emailContent,
customMessage: customMessage.trim() || undefined,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
} catch (error) {
// Error handling is done in the mutation's onError
console.error("Send email error:", error);
}
};
const handleConfirmSend = () => {
setIsConfirming(true);
setActiveTab("confirm");
};
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
const toEmail = invoice?.client?.email ?? "";
const canSend =
!isSending &&
subject.trim() &&
emailContent.trim() &&
toEmail &&
toEmail.trim() !== "";
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="text-primary h-5 w-5" />
Send Invoice Email
</DialogTitle>
<DialogDescription>
Compose and preview your invoice email before sending to{" "}
{invoice?.client?.name ?? "client"}.
</DialogDescription>
</DialogHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This client doesn&apos;t have an email address. Please add an
email address to the client before sending the invoice.
</AlertDescription>
</Alert>
)}
{/* Branded Template Info */}
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>
<strong>Professional Email Template:</strong> Your email will be
sent using a beautifully designed, beenvoice-branded template with
proper fonts and styling. Any custom content you add will be
incorporated into the professional template automatically.
</AlertDescription>
</Alert>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="min-h-0 flex-1"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="compose" className="flex items-center gap-2">
<Edit3 className="h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
<TabsTrigger
value="confirm"
className="flex items-center gap-2"
disabled={!isConfirming}
>
<CheckCircle className="h-4 w-4" />
Confirm
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent
value="compose"
className="mt-4 h-full overflow-y-auto"
>
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
</TabsContent>
<TabsContent
value="preview"
className="mt-4 h-full overflow-y-auto"
>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
className="pr-2"
/>
</TabsContent>
<TabsContent
value="confirm"
className="mt-4 h-full overflow-y-auto"
>
<div className="space-y-6 pr-2">
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
You&apos;re about to send this email to{" "}
<strong>{toEmail}</strong>. The invoice PDF will be
automatically attached.
</AlertDescription>
</Alert>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
/>
{invoice?.status === "draft" && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This invoice is currently in <strong>draft</strong>{" "}
status. Sending it will automatically change the status to{" "}
<strong>sent</strong>.
</AlertDescription>
</Alert>
)}
</div>
</TabsContent>
</div>
</Tabs>
<DialogFooter className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeTab === "compose" && (
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
)}
{activeTab === "preview" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("compose")}
disabled={isSending}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
onClick={handleConfirmSend}
disabled={!canSend}
variant="default"
>
<CheckCircle className="mr-2 h-4 w-4" />
Review & Send
</Button>
</>
)}
{activeTab === "confirm" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
Back to Preview
</Button>
<Button
onClick={handleSendEmail}
disabled={!canSend || isSending}
className="bg-primary hover:bg-primary/90"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send Email
</>
)}
</Button>
</>
)}
</div>
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
disabled={isSending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+7 -1
View File
@@ -8,7 +8,11 @@ import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export function Navbar() { interface NavbarProps {
allowRegistration?: boolean;
}
export function Navbar({ allowRegistration = true }: NavbarProps) {
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false; // const session = { user: null } as any; const isPending = false;
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
@@ -63,6 +67,7 @@ export function Navbar() {
Sign In Sign In
</Button> </Button>
</Link> </Link>
{allowRegistration && (
<Link href="/auth/register"> <Link href="/auth/register">
<Button <Button
size="sm" size="sm"
@@ -72,6 +77,7 @@ export function Navbar() {
Register Register
</Button> </Button>
</Link> </Link>
)}
</> </>
)} )}
</div> </div>
+5 -1
View File
@@ -19,6 +19,7 @@ export const env = createEnv({
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
DB_DISABLE_SSL: z.coerce.boolean().optional(), DB_DISABLE_SSL: z.coerce.boolean().optional(),
DISABLE_SIGNUPS: z.coerce.boolean().optional(),
// SSO / Authentik (optional) // SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(), AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(), AUTHENTIK_CLIENT_ID: z.string().optional(),
@@ -52,7 +53,9 @@ export const env = createEnv({
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
.enum(["brand", "platform", "inter", "serif"]) .enum(["brand", "platform", "inter", "serif"])
.optional(), .optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z.enum(["none", "sm", "md", "lg", "xl"]).optional(), NEXT_PUBLIC_DEFAULT_RADIUS: z
.enum(["none", "sm", "md", "lg", "xl"])
.optional(),
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
.enum(["floating", "docked"]) .enum(["floating", "docked"])
.optional(), .optional(),
@@ -70,6 +73,7 @@ export const env = createEnv({
RESEND_DOMAIN: process.env.RESEND_DOMAIN, RESEND_DOMAIN: process.env.RESEND_DOMAIN,
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
DB_DISABLE_SSL: process.env.DB_DISABLE_SSL, DB_DISABLE_SSL: process.env.DB_DISABLE_SSL,
DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS,
AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER, AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER,
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET, AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
+2
View File
@@ -10,6 +10,7 @@ const authentikEnabled = Boolean(
process.env.AUTHENTIK_CLIENT_ID && process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET, process.env.AUTHENTIK_CLIENT_SECRET,
); );
const signupsDisabled = process.env.DISABLE_SIGNUPS === "true";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -34,6 +35,7 @@ export const auth = betterAuth({
}), }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
disableSignUp: signupsDisabled,
password: { password: {
hash: async (password) => { hash: async (password) => {
const bcrypt = await import("bcryptjs"); const bcrypt = await import("bcryptjs");
+1
View File
@@ -778,6 +778,7 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<View style={styles.footerLogo}> <View style={styles.footerLogo}>
{settings.pdfShowLogo && ( {settings.pdfShowLogo && (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
<Image <Image
src="/beenvoice-logo.png" src="/beenvoice-logo.png"
style={{ style={{
+6
View File
@@ -4,6 +4,12 @@ import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (pathname === "/auth/register" && process.env.DISABLE_SIGNUPS === "true") {
const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("signup", "disabled");
return NextResponse.redirect(signInUrl);
}
// Define public routes that don't require authentication // Define public routes that don't require authentication
const publicRoutes = ["/", "/auth/signin", "/auth/register"]; const publicRoutes = ["/", "/auth/signin", "/auth/register"];
+10 -10
View File
@@ -58,11 +58,11 @@ export const expensesRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const clean = { const clean = {
...input, ...input,
clientId: input.clientId?.trim() || null, clientId: input.clientId?.trim() ?? null,
businessId: input.businessId?.trim() || null, businessId: input.businessId?.trim() ?? null,
invoiceId: input.invoiceId?.trim() || null, invoiceId: input.invoiceId?.trim() ?? null,
category: input.category?.trim() || null, category: input.category?.trim() ?? null,
notes: input.notes?.trim() || null, notes: input.notes?.trim() ?? null,
}; };
if (clean.clientId) { if (clean.clientId) {
@@ -121,11 +121,11 @@ export const expensesRouter = createTRPCRouter({
const clean = { const clean = {
...data, ...data,
clientId: data.clientId?.trim() || null, clientId: data.clientId?.trim() ?? null,
businessId: data.businessId?.trim() || null, businessId: data.businessId?.trim() ?? null,
invoiceId: data.invoiceId?.trim() || null, invoiceId: data.invoiceId?.trim() ?? null,
category: data.category?.trim() || null, category: data.category?.trim() ?? null,
notes: data.notes?.trim() || null, notes: data.notes?.trim() ?? null,
updatedAt: new Date(), updatedAt: new Date(),
}; };
+31 -4
View File
@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { import {
@@ -8,6 +8,7 @@ import {
publicProcedure, publicProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { import {
accounts,
users, users,
clients, clients,
businesses, businesses,
@@ -29,9 +30,10 @@ import {
type RadiusPreference, type RadiusPreference,
type SidebarStyle, type SidebarStyle,
} from "~/lib/branding"; } from "~/lib/branding";
import type { db as database } from "~/server/db";
async function requireAdmin(ctx: { async function requireAdmin(ctx: {
db: typeof import("~/server/db").db; db: typeof database;
session: { user: { id: string } }; session: { user: { id: string } };
}) { }) {
const user = await ctx.db.query.users.findFirst({ const user = await ctx.db.query.users.findFirst({
@@ -425,14 +427,39 @@ export const settingsRouter = createTRPCRouter({
saltRounds, saltRounds,
); );
// Update the password await ctx.db.transaction(async (tx) => {
await ctx.db await tx
.update(users) .update(users)
.set({ .set({
password: hashedNewPassword, password: hashedNewPassword,
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, userId),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedNewPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId,
accountId: userId,
providerId: "credential",
password: hashedNewPassword,
});
}
});
return { success: true }; return { success: true };
}), }),
+4 -4
View File
@@ -8,6 +8,7 @@ import { useState } from "react";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import { createQueryClient } from "./query-client"; import { createQueryClient } from "./query-client";
import type { AppRouter } from "~/server/api/root";
let clientQueryClientSingleton: QueryClient | undefined = undefined; let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => { const getQueryClient = () => {
@@ -21,22 +22,21 @@ const getQueryClient = () => {
return clientQueryClientSingleton; return clientQueryClientSingleton;
}; };
// Use inline import() type to avoid pulling server modules into the client bundle export const api = createTRPCReact<AppRouter>();
export const api = createTRPCReact<import("~/server/api/root").AppRouter>();
/** /**
* Inference helper for inputs. * Inference helper for inputs.
* *
* @example type HelloInput = RouterInputs['example']['hello'] * @example type HelloInput = RouterInputs['example']['hello']
*/ */
export type RouterInputs = inferRouterInputs<import("~/server/api/root").AppRouter>; export type RouterInputs = inferRouterInputs<AppRouter>;
/** /**
* Inference helper for outputs. * Inference helper for outputs.
* *
* @example type HelloOutput = RouterOutputs['example']['hello'] * @example type HelloOutput = RouterOutputs['example']['hello']
*/ */
export type RouterOutputs = inferRouterOutputs<import("~/server/api/root").AppRouter>; export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function TRPCReactProvider(props: { children: React.ReactNode }) { export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient(); const queryClient = getQueryClient();