mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
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.
This commit is contained in:
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: "",
|
||||||
export default function TemplatesPage() {
|
type: "notes",
|
||||||
const [open, setOpen] = useState(false);
|
content: "",
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
isDefault: false,
|
||||||
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: 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");
|
type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
|
||||||
const termsTemplates = templates.filter((t) => t.type === "terms");
|
|
||||||
|
|
||||||
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => (
|
interface TemplateListProps {
|
||||||
|
items: InvoiceTemplate[];
|
||||||
|
type: "notes" | "terms";
|
||||||
|
isLoading: boolean;
|
||||||
|
onCreate: (type: "notes" | "terms") => void;
|
||||||
|
onEdit: (template: InvoiceTemplate) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateList({
|
||||||
|
items,
|
||||||
|
type,
|
||||||
|
isLoading,
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: TemplateListProps) {
|
||||||
|
return (
|
||||||
<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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user