Refactored a bunch of shit
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m57s

This commit is contained in:
2026-02-09 01:49:19 -07:00
parent c39865031a
commit 12d59bb42f
40 changed files with 844 additions and 678 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "chronus", "name": "chronus",
"type": "module", "type": "module",
"version": "2.3.0", "version": "2.4.0",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
@@ -13,7 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "0.9.6", "@astrojs/check": "0.9.6",
"@astrojs/node": "10.0.0-beta.0", "@astrojs/node": "10.0.0-beta.2",
"@astrojs/vue": "6.0.0-beta.0", "@astrojs/vue": "6.0.0-beta.0",
"@ceereals/vue-pdf": "^0.2.1", "@ceereals/vue-pdf": "^0.2.1",
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
@@ -21,23 +21,23 @@
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"astro": "6.0.0-beta.6", "astro": "6.0.0-beta.9",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"daisyui": "^5.5.17", "daisyui": "^5.5.18",
"dotenv": "^17.2.3", "dotenv": "^17.2.4",
"drizzle-orm": "0.45.1", "drizzle-orm": "0.45.1",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vue": "^3.5.27", "vue": "^3.5.28",
"vue-chartjs": "^5.3.3" "vue-chartjs": "^5.3.3"
}, },
"devDependencies": { "devDependencies": {
"@catppuccin/daisyui": "^2.1.1", "@catppuccin/daisyui": "^2.1.1",
"@iconify-json/heroicons": "^1.2.3", "@iconify-json/heroicons": "^1.2.3",
"@react-pdf/types": "^2.9.2", "@react-pdf/types": "^2.9.2",
"drizzle-kit": "0.31.8" "drizzle-kit": "0.31.9"
} }
} }

664
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
---
import { Icon } from 'astro-icon/components';
interface Props {
title: string;
value: string;
description?: string;
icon?: string;
color?: string;
valueClass?: string;
}
const { title, value, description, icon, color = 'text-primary', valueClass } = Astro.props;
---
<div class="stat">
{icon && (
<div class:list={["stat-figure", color]}>
<Icon name={icon} class="w-8 h-8" />
</div>
)}
<div class="stat-title">{title}</div>
<div class:list={["stat-value", color, valueClass]}>{value}</div>
{description && <div class="stat-desc">{description}</div>}
</div>

View File

@@ -25,3 +25,16 @@ export function formatTimeRange(start: Date, end: Date | null): string {
const ms = end.getTime() - start.getTime(); const ms = end.getTime() - start.getTime();
return formatDuration(ms); return formatDuration(ms);
} }
/**
* Formats a cent-based amount as a currency string.
* @param amount - Amount in cents (e.g. 1500 = $15.00)
* @param currency - ISO 4217 currency code (default: 'USD')
* @returns Formatted currency string like "$15.00"
*/
export function formatCurrency(amount: number, currency: string = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
}).format(amount / 100);
}

24
src/lib/getCurrentTeam.ts Normal file
View File

@@ -0,0 +1,24 @@
import { db } from '../db';
import { members } from '../db/schema';
import { eq } from 'drizzle-orm';
type User = { id: string; [key: string]: any };
/**
* Get the current team membership for a user based on the currentTeamId cookie.
* Returns the membership row, or null if the user has no memberships.
*/
export async function getCurrentTeam(user: User, currentTeamId?: string | null) {
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return null;
const membership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
return membership;
}

View File

@@ -2,6 +2,30 @@ import { db } from "../db";
import { clients, tags as tagsTable } from "../db/schema"; import { clients, tags as tagsTable } from "../db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
export const MAX_LENGTHS = {
name: 255,
email: 320,
password: 128,
phone: 50,
address: 255, // street, city, state, zip, country
currency: 10,
invoiceNumber: 50,
invoiceNotes: 5000,
itemDescription: 2000,
description: 2000, // time entry description
} as const;
export function exceedsLength(
field: string,
value: string | null | undefined,
maxLength: number,
): string | null {
if (value && value.length > maxLength) {
return `${field} must be ${maxLength} characters or fewer`;
}
return null;
}
export async function validateTimeEntryResources({ export async function validateTimeEntryResources({
organizationId, organizationId,
clientId, clientId,
@@ -60,3 +84,9 @@ export function validateTimeRange(
return { valid: true, startDate, endDate }; return { valid: true, startDate, endDate };
} }
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(email: string): boolean {
return EMAIL_REGEX.test(email) && email.length <= 320;
}

View File

@@ -65,7 +65,8 @@ export const POST: APIRoute = async ({ request, cookies }) => {
}, },
}); });
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), { console.error("Passkey authentication verification failed:", error);
return new Response(JSON.stringify({ error: "Verification failed" }), {
status: 400, status: 400,
}); });
} }

View File

@@ -2,8 +2,13 @@ import type { APIRoute } from "astro";
import { generateAuthenticationOptions } from "@simplewebauthn/server"; import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db"; import { db } from "../../../../../db";
import { passkeyChallenges } from "../../../../../db/schema"; import { passkeyChallenges } from "../../../../../db/schema";
import { lte } from "drizzle-orm";
export const GET: APIRoute = async ({ request }) => { export const GET: APIRoute = async ({ request }) => {
await db
.delete(passkeyChallenges)
.where(lte(passkeyChallenges.expiresAt, new Date()));
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID: new URL(request.url).hostname, rpID: new URL(request.url).hostname,
userVerification: "preferred", userVerification: "preferred",

View File

@@ -48,7 +48,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
expectedRPID: new URL(request.url).hostname, expectedRPID: new URL(request.url).hostname,
}); });
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), { console.error("Passkey registration verification failed:", error);
return new Response(JSON.stringify({ error: "Verification failed" }), {
status: 400, status: 400,
}); });
} }

View File

@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import { generateRegistrationOptions } from "@simplewebauthn/server"; import { generateRegistrationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db"; import { db } from "../../../../../db";
import { passkeys, passkeyChallenges } from "../../../../../db/schema"; import { passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq } from "drizzle-orm"; import { eq, lte } from "drizzle-orm";
export const GET: APIRoute = async ({ request, locals }) => { export const GET: APIRoute = async ({ request, locals }) => {
const user = locals.user; const user = locals.user;
@@ -13,6 +13,10 @@ export const GET: APIRoute = async ({ request, locals }) => {
}); });
} }
await db
.delete(passkeyChallenges)
.where(lte(passkeyChallenges.expiresAt, new Date()));
const userPasskeys = await db.query.passkeys.findMany({ const userPasskeys = await db.query.passkeys.findMany({
where: eq(passkeys.userId, user.id), where: eq(passkeys.userId, user.id),
}); });

View File

@@ -7,6 +7,7 @@ import {
siteSettings, siteSettings,
} from "../../../db/schema"; } from "../../../db/schema";
import { hashPassword, createSession } from "../../../lib/auth"; import { hashPassword, createSession } from "../../../lib/auth";
import { isValidEmail, MAX_LENGTHS } from "../../../lib/validation";
import { eq, count, sql } from "drizzle-orm"; import { eq, count, sql } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -37,6 +38,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
return redirect("/signup?error=missing_fields"); return redirect("/signup?error=missing_fields");
} }
if (!isValidEmail(email)) {
return redirect("/signup?error=invalid_email");
}
if (name.length > MAX_LENGTHS.name) {
return redirect("/signup?error=name_too_long");
}
if (password.length > MAX_LENGTHS.password) {
return redirect("/signup?error=password_too_long");
}
if (password.length < 8) { if (password.length < 8) {
return redirect("/signup?error=password_too_short"); return redirect("/signup?error=password_too_short");
} }
@@ -47,7 +60,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
.where(eq(users.email, email)) .where(eq(users.email, email))
.get(); .get();
if (existingUser) { if (existingUser) {
return redirect("/signup?error=user_exists"); return redirect("/login?registered=true");
} }
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);

View File

@@ -52,6 +52,17 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
return new Response("Not authorized", { status: 403 }); return new Response("Not authorized", { status: 403 });
} }
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Only owners and admins can delete clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can delete clients", { status: 403 });
}
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run(); await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
await db.delete(clients).where(eq(clients.id, id)).run(); await db.delete(clients).where(eq(clients.id, id)).run();

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
import { db } from "../../../../db"; import { db } from "../../../../db";
import { clients, members } from "../../../../db/schema"; import { clients, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
export const POST: APIRoute = async ({ request, params, locals, redirect }) => { export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -49,6 +50,25 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return new Response("Client name is required", { status: 400 }); return new Response("Client name is required", { status: 400 });
} }
const lengthError =
exceedsLength("Name", name, MAX_LENGTHS.name) ||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
exceedsLength("City", city, MAX_LENGTHS.address) ||
exceedsLength("State", state, MAX_LENGTHS.address) ||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
exceedsLength("Country", country, MAX_LENGTHS.address);
if (lengthError) {
if (locals.scopes) {
return new Response(JSON.stringify({ error: lengthError }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return new Response(lengthError, { status: 400 });
}
try { try {
const client = await db const client = await db
.select() .select()
@@ -87,6 +107,17 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return new Response("Not authorized", { status: 403 }); return new Response("Not authorized", { status: 403 });
} }
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Only owners and admins can update clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can update clients", { status: 403 });
}
await db await db
.update(clients) .update(clients)
.set({ .set({

View File

@@ -3,6 +3,7 @@ import { db } from "../../../db";
import { clients, members } from "../../../db/schema"; import { clients, members } from "../../../db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => { export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -45,6 +46,25 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response("Name is required", { status: 400 }); return new Response("Name is required", { status: 400 });
} }
const lengthError =
exceedsLength("Name", name, MAX_LENGTHS.name) ||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
exceedsLength("City", city, MAX_LENGTHS.address) ||
exceedsLength("State", state, MAX_LENGTHS.address) ||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
exceedsLength("Country", country, MAX_LENGTHS.address);
if (lengthError) {
if (locals.scopes) {
return new Response(JSON.stringify({ error: lengthError }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return new Response(lengthError, { status: 400 });
}
const userOrg = await db const userOrg = await db
.select() .select()
.from(members) .from(members)
@@ -55,6 +75,17 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response("No organization found", { status: 400 }); return new Response("No organization found", { status: 400 });
} }
const isAdminOrOwner = userOrg.role === "owner" || userOrg.role === "admin";
if (!isAdminOrOwner) {
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Only owners and admins can create clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can create clients", { status: 403 });
}
const id = nanoid(); const id = nanoid();
await db.insert(clients).values({ await db.insert(clients).values({

View File

@@ -45,6 +45,11 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
return new Response("Only owners and admins can convert quotes", { status: 403 });
}
try { try {
const lastInvoice = await db const lastInvoice = await db
.select() .select()

View File

@@ -107,7 +107,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
return new Response(buffer, { return new Response(buffer, {
headers: { headers: {
"Content-Type": "application/pdf", "Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoice.number}.pdf"`, "Content-Disposition": `attachment; filename="${invoice.number.replace(/[^a-zA-Z0-9_\-\.]/g, "_")}.pdf"`,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -222,51 +222,52 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return redirect(`/dashboard/invoices/${id}?error=no-entries`); return redirect(`/dashboard/invoices/${id}?error=no-entries`);
} }
// Transaction-like operations
try { try {
await db.insert(invoiceItems).values(newItems); await db.transaction(async (tx) => {
await tx.insert(invoiceItems).values(newItems);
if (entryIdsToUpdate.length > 0) { if (entryIdsToUpdate.length > 0) {
await db await tx
.update(timeEntries) .update(timeEntries)
.set({ invoiceId: invoice.id }) .set({ invoiceId: invoice.id })
.where(inArray(timeEntries.id, entryIdsToUpdate)); .where(inArray(timeEntries.id, entryIdsToUpdate));
}
const allItems = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id));
const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
let discountAmount = 0;
if (invoice.discountType === "percentage") {
discountAmount = Math.round(
subtotal * ((invoice.discountValue || 0) / 100),
);
} else {
discountAmount = Math.round((invoice.discountValue || 0) * 100);
if (invoice.discountValue && invoice.discountValue > 0) {
discountAmount = Math.round((invoice.discountValue || 0) * 100);
} }
}
const taxableAmount = Math.max(0, subtotal - discountAmount); const allItems = await tx
const taxAmount = Math.round( .select()
taxableAmount * ((invoice.taxRate || 0) / 100), .from(invoiceItems)
); .where(eq(invoiceItems.invoiceId, invoice.id));
const total = subtotal - discountAmount + taxAmount;
await db const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
.update(invoices)
.set({ let discountAmount = 0;
subtotal, if (invoice.discountType === "percentage") {
discountAmount, discountAmount = Math.round(
taxAmount, subtotal * ((invoice.discountValue || 0) / 100),
total, );
}) } else {
.where(eq(invoices.id, invoice.id)); discountAmount = Math.round((invoice.discountValue || 0) * 100);
if (invoice.discountValue && invoice.discountValue > 0) {
discountAmount = Math.round((invoice.discountValue || 0) * 100);
}
}
const taxableAmount = Math.max(0, subtotal - discountAmount);
const taxAmount = Math.round(
taxableAmount * ((invoice.taxRate || 0) / 100),
);
const total = subtotal - discountAmount + taxAmount;
await tx
.update(invoices)
.set({
subtotal,
discountAmount,
taxAmount,
total,
})
.where(eq(invoices.id, invoice.id));
});
return redirect(`/dashboard/invoices/${id}?success=imported`); return redirect(`/dashboard/invoices/${id}?success=imported`);
} catch (error) { } catch (error) {

View File

@@ -3,6 +3,7 @@ import { db } from "../../../../../db";
import { invoiceItems, invoices, members } from "../../../../../db/schema"; import { invoiceItems, invoices, members } from "../../../../../db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../../utils/invoice"; import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
import { MAX_LENGTHS, exceedsLength } from "../../../../../lib/validation";
export const POST: APIRoute = async ({ export const POST: APIRoute = async ({
request, request,
@@ -61,6 +62,11 @@ export const POST: APIRoute = async ({
return new Response("Missing required fields", { status: 400 }); return new Response("Missing required fields", { status: 400 });
} }
const lengthError = exceedsLength("Description", description, MAX_LENGTHS.itemDescription);
if (lengthError) {
return new Response(lengthError, { status: 400 });
}
const quantity = parseFloat(quantityStr); const quantity = parseFloat(quantityStr);
const unitPriceMajor = parseFloat(unitPriceStr); const unitPriceMajor = parseFloat(unitPriceStr);

View File

@@ -60,6 +60,13 @@ export const POST: APIRoute = async ({
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
// Destructive status changes require owner/admin
const destructiveStatuses = ["void"];
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (destructiveStatuses.includes(status) && !isAdminOrOwner) {
return new Response("Only owners and admins can void invoices", { status: 403 });
}
try { try {
await db await db
.update(invoices) .update(invoices)

View File

@@ -3,6 +3,7 @@ import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema"; import { invoices, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../utils/invoice"; import { recalculateInvoiceTotals } from "../../../../utils/invoice";
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
export const POST: APIRoute = async ({ request, redirect, locals, params }) => { export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
const user = locals.user; const user = locals.user;
@@ -56,6 +57,14 @@ export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
return new Response("Missing required fields", { status: 400 }); return new Response("Missing required fields", { status: 400 });
} }
const lengthError =
exceedsLength("Invoice number", number, MAX_LENGTHS.invoiceNumber) ||
exceedsLength("Currency", currency, MAX_LENGTHS.currency) ||
exceedsLength("Notes", notes, MAX_LENGTHS.invoiceNotes);
if (lengthError) {
return new Response(lengthError, { status: 400 });
}
try { try {
const issueDate = new Date(issueDateStr); const issueDate = new Date(issueDateStr);
const dueDate = new Date(dueDateStr); const dueDate = new Date(dueDateStr);

View File

@@ -43,6 +43,11 @@ export const POST: APIRoute = async ({ request, redirect, locals }) => {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
return new Response("Only owners and admins can delete invoices", { status: 403 });
}
try { try {
// Delete invoice items first (manual cascade) // Delete invoice items first (manual cascade)
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId)); await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));

View File

@@ -4,6 +4,7 @@ import path from "path";
import { db } from "../../../db"; import { db } from "../../../db";
import { organizations, members } from "../../../db/schema"; import { organizations, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => { export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -29,6 +30,18 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
}); });
} }
const lengthError =
exceedsLength("Name", name, MAX_LENGTHS.name) ||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
exceedsLength("City", city, MAX_LENGTHS.address) ||
exceedsLength("State", state, MAX_LENGTHS.address) ||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
exceedsLength("Country", country, MAX_LENGTHS.address) ||
exceedsLength("Currency", defaultCurrency, MAX_LENGTHS.currency);
if (lengthError) {
return new Response(lengthError, { status: 400 });
}
try { try {
// Verify user is admin/owner of this organization // Verify user is admin/owner of this organization
const membership = await db const membership = await db
@@ -67,7 +80,9 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
); );
} }
const ext = logo.name.split(".").pop() || "png"; const rawExt = (logo.name.split(".").pop() || "png").toLowerCase().replace(/[^a-z]/g, "");
const allowedExtensions = ["png", "jpg", "jpeg"];
const ext = allowedExtensions.includes(rawExt) ? rawExt : "png";
const filename = `${organizationId}-${Date.now()}.${ext}`; const filename = `${organizationId}-${Date.now()}.${ext}`;
const dataDir = process.env.DATA_DIR const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR ? process.env.DATA_DIR

View File

@@ -128,6 +128,13 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
"Tag", "Tag",
"Description", "Description",
]; ];
const sanitizeCell = (value: string): string => {
if (/^[=+\-@\t\r]/.test(value)) {
return `\t${value}`;
}
return value;
};
const rows = entries.map((e) => { const rows = entries.map((e) => {
const start = e.entry.startTime; const start = e.entry.startTime;
const end = e.entry.endTime; const end = e.entry.endTime;
@@ -144,10 +151,10 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
start.toLocaleTimeString(), start.toLocaleTimeString(),
end ? end.toLocaleTimeString() : "", end ? end.toLocaleTimeString() : "",
end ? duration.toFixed(2) : "Running", end ? duration.toFixed(2) : "Running",
`"${(e.user.name || "").replace(/"/g, '""')}"`, `"${sanitizeCell((e.user.name || "").replace(/"/g, '""'))}"`,
`"${(e.client.name || "").replace(/"/g, '""')}"`, `"${sanitizeCell((e.client.name || "").replace(/"/g, '""'))}"`,
`"${tagsStr.replace(/"/g, '""')}"`, `"${sanitizeCell(tagsStr.replace(/"/g, '""'))}"`,
`"${(e.entry.description || "").replace(/"/g, '""')}"`, `"${sanitizeCell((e.entry.description || "").replace(/"/g, '""'))}"`,
].join(","); ].join(",");
}); });

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { db } from '../../../db'; import { db } from '../../../db';
import { users, members } from '../../../db/schema'; import { users, members } from '../../../db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { isValidEmail } from '../../../lib/validation';
export const POST: APIRoute = async ({ request, locals, redirect }) => { export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -26,6 +27,10 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response('Email is required', { status: 400 }); return new Response('Email is required', { status: 400 });
} }
if (!isValidEmail(email)) {
return new Response('Invalid email format', { status: 400 });
}
if (!['member', 'admin'].includes(role)) { if (!['member', 'admin'].includes(role)) {
return new Response('Invalid role', { status: 400 }); return new Response('Invalid role', { status: 400 });
} }

View File

@@ -6,6 +6,7 @@ import { nanoid } from "nanoid";
import { import {
validateTimeEntryResources, validateTimeEntryResources,
validateTimeRange, validateTimeRange,
MAX_LENGTHS,
} from "../../../lib/validation"; } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
@@ -27,6 +28,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
}); });
} }
if (description && description.length > MAX_LENGTHS.description) {
return new Response(
JSON.stringify({ error: `Description must be ${MAX_LENGTHS.description} characters or fewer` }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (!startTime) { if (!startTime) {
return new Response(JSON.stringify({ error: "Start time is required" }), { return new Response(JSON.stringify({ error: "Start time is required" }), {
status: 400, status: 400,

View File

@@ -3,7 +3,7 @@ import { db } from "../../../db";
import { timeEntries, members } from "../../../db/schema"; import { timeEntries, members } from "../../../db/schema";
import { eq, and, isNull } from "drizzle-orm"; import { eq, and, isNull } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { validateTimeEntryResources } from "../../../lib/validation"; import { validateTimeEntryResources, MAX_LENGTHS } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response("Unauthorized", { status: 401 }); if (!locals.user) return new Response("Unauthorized", { status: 401 });
@@ -17,6 +17,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
return new Response("Client is required", { status: 400 }); return new Response("Client is required", { status: 400 });
} }
if (description && description.length > MAX_LENGTHS.description) {
return new Response(`Description must be ${MAX_LENGTHS.description} characters or fewer`, { status: 400 });
}
const runningEntry = await db const runningEntry = await db
.select() .select()
.from(timeEntries) .from(timeEntries)

View File

@@ -1,10 +1,11 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { db } from "../../../db"; import { db } from "../../../db";
import { users } from "../../../db/schema"; import { users, sessions } from "../../../db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { MAX_LENGTHS } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => { export const POST: APIRoute = async ({ request, locals, redirect, cookies }) => {
const user = locals.user; const user = locals.user;
const contentType = request.headers.get("content-type"); const contentType = request.headers.get("content-type");
const isJson = contentType?.includes("application/json"); const isJson = contentType?.includes("application/json");
@@ -53,6 +54,13 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response(msg, { status: 400 }); return new Response(msg, { status: 400 });
} }
if (currentPassword.length > MAX_LENGTHS.password || newPassword.length > MAX_LENGTHS.password) {
const msg = `Password must be ${MAX_LENGTHS.password} characters or fewer`;
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
try { try {
// Get current user from database // Get current user from database
const dbUser = await db const dbUser = await db
@@ -90,6 +98,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
.where(eq(users.id, user.id)) .where(eq(users.id, user.id))
.run(); .run();
// Invalidate all sessions, then re-create one for the current user
const currentSessionId = cookies.get("session_id")?.value;
if (currentSessionId) {
await db
.delete(sessions)
.where(
eq(sessions.userId, user.id),
)
.run();
const { createSession } = await import("../../../lib/auth");
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
} else {
await db
.delete(sessions)
.where(eq(sessions.userId, user.id))
.run();
}
if (isJson) { if (isJson) {
return new Response(JSON.stringify({ success: true }), { status: 200 }); return new Response(JSON.stringify({ success: true }), { status: 200 });
} }

View File

@@ -1,26 +1,15 @@
--- ---
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { db } from '../../db'; import { db } from '../../db';
import { clients, members } from '../../db/schema'; import { clients } from '../../db/schema';
import { eq, and } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const organizationId = userMembership.organizationId; const organizationId = userMembership.organizationId;

View File

@@ -2,8 +2,9 @@
import DashboardLayout from '../../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { db } from '../../../../db'; import { db } from '../../../../db';
import { clients, members } from '../../../../db/schema'; import { clients } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
@@ -11,20 +12,8 @@ if (!user) return Astro.redirect('/login');
const { id } = Astro.params; const { id } = Astro.params;
if (!id) return Astro.redirect('/dashboard/clients'); if (!id) return Astro.redirect('/dashboard/clients');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const client = await db.select() const client = await db.select()
.from(clients) .from(clients)

View File

@@ -2,9 +2,11 @@
import DashboardLayout from '../../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { db } from '../../../../db'; import { db } from '../../../../db';
import { clients, timeEntries, members, tags, users } from '../../../../db/schema'; import { clients, timeEntries, tags, users } from '../../../../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm'; import { eq, and, desc, sql } from 'drizzle-orm';
import { formatTimeRange } from '../../../../lib/formatTime'; import { formatTimeRange } from '../../../../lib/formatTime';
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
import StatCard from '../../../../components/StatCard.astro';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
@@ -12,20 +14,8 @@ if (!user) return Astro.redirect('/login');
const { id } = Astro.params; const { id } = Astro.params;
if (!id) return Astro.redirect('/dashboard/clients'); if (!id) return Astro.redirect('/dashboard/clients');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const client = await db.select() const client = await db.select()
.from(clients) .from(clients)
@@ -132,23 +122,20 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<div class="divider"></div> <div class="divider"></div>
<div class="stats shadow w-full"> <div class="stats shadow w-full">
<div class="stat"> <StatCard
<div class="stat-figure text-primary"> title="Total Time Tracked"
<Icon name="heroicons:clock" class="w-8 h-8" /> value={`${totalHours}h ${totalMinutes}m`}
</div> description="Across all projects"
<div class="stat-title">Total Time Tracked</div> icon="heroicons:clock"
<div class="stat-value text-primary">{totalHours}h {totalMinutes}m</div> color="text-primary"
<div class="stat-desc">Across all projects</div> />
</div> <StatCard
title="Total Entries"
<div class="stat"> value={String(totalEntriesCount)}
<div class="stat-figure text-secondary"> description="Recorded entries"
<Icon name="heroicons:list-bullet" class="w-8 h-8" /> icon="heroicons:list-bullet"
</div> color="text-secondary"
<div class="stat-title">Total Entries</div> />
<div class="stat-value text-secondary">{totalEntriesCount}</div>
<div class="stat-desc">Recorded entries</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
--- ---
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import StatCard from '../../components/StatCard.astro';
import { db } from '../../db'; import { db } from '../../db';
import { organizations, members, timeEntries, clients, tags } from '../../db/schema'; import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm'; import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
@@ -134,41 +135,38 @@ const hasMembership = userOrgs.length > 0;
<> <>
<!-- Stats Overview --> <!-- Stats Overview -->
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8"> <div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8">
<div class="stat"> <StatCard
<div class="stat-figure text-primary"> title="This Week"
<Icon name="heroicons:clock" class="w-8 h-8" /> value={formatDuration(stats.totalTimeThisWeek)}
</div> description="Total tracked time"
<div class="stat-title">This Week</div> icon="heroicons:clock"
<div class="stat-value text-primary text-3xl">{formatDuration(stats.totalTimeThisWeek)}</div> color="text-primary"
<div class="stat-desc">Total tracked time</div> valueClass="text-3xl"
</div> />
<StatCard
<div class="stat"> title="This Month"
<div class="stat-figure text-secondary"> value={formatDuration(stats.totalTimeThisMonth)}
<Icon name="heroicons:calendar" class="w-8 h-8" /> description="Total tracked time"
</div> icon="heroicons:calendar"
<div class="stat-title">This Month</div> color="text-secondary"
<div class="stat-value text-secondary text-3xl">{formatDuration(stats.totalTimeThisMonth)}</div> valueClass="text-3xl"
<div class="stat-desc">Total tracked time</div> />
</div> <StatCard
title="Active Timers"
<div class="stat"> value={String(stats.activeTimers)}
<div class="stat-figure text-accent"> description="Currently running"
<Icon name="heroicons:play-circle" class="w-8 h-8" /> icon="heroicons:play-circle"
</div> color="text-accent"
<div class="stat-title">Active Timers</div> valueClass="text-3xl"
<div class="stat-value text-accent text-3xl">{stats.activeTimers}</div> />
<div class="stat-desc">Currently running</div> <StatCard
</div> title="Clients"
value={String(stats.totalClients)}
<div class="stat"> description="Total active"
<div class="stat-figure text-info"> icon="heroicons:building-office"
<Icon name="heroicons:building-office" class="w-8 h-8" /> color="text-info"
</div> valueClass="text-3xl"
<div class="stat-title">Clients</div> />
<div class="stat-value text-info text-3xl">{stats.totalClients}</div>
<div class="stat-desc">Total active</div>
</div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">

View File

@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components';
import { db } from '../../../db'; import { db } from '../../../db';
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema'; import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { formatCurrency } from '../../../lib/formatTime';
const { id } = Astro.params; const { id } = Astro.params;
const user = Astro.locals.user; const user = Astro.locals.user;
@@ -49,13 +50,6 @@ const items = await db.select()
.where(eq(invoiceItems.invoiceId, invoice.id)) .where(eq(invoiceItems.invoiceId, invoice.id))
.all(); .all();
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: invoice.currency,
}).format(amount / 100);
};
const isDraft = invoice.status === 'draft'; const isDraft = invoice.status === 'draft';
--- ---
@@ -235,8 +229,8 @@ const isDraft = invoice.status === 'draft';
<tr> <tr>
<td class="py-4">{item.description}</td> <td class="py-4">{item.description}</td>
<td class="py-4 text-right">{item.quantity}</td> <td class="py-4 text-right">{item.quantity}</td>
<td class="py-4 text-right">{formatCurrency(item.unitPrice)}</td> <td class="py-4 text-right">{formatCurrency(item.unitPrice, invoice.currency)}</td>
<td class="py-4 text-right font-medium">{formatCurrency(item.amount)}</td> <td class="py-4 text-right font-medium">{formatCurrency(item.amount, invoice.currency)}</td>
{isDraft && ( {isDraft && (
<td class="py-4 text-right"> <td class="py-4 text-right">
<form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}> <form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}>
@@ -299,7 +293,7 @@ const isDraft = invoice.status === 'draft';
<div class="w-64 space-y-3"> <div class="w-64 space-y-3">
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-base-content/60">Subtotal</span> <span class="text-base-content/60">Subtotal</span>
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span> <span class="font-medium">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div> </div>
{(invoice.discountAmount && invoice.discountAmount > 0) && ( {(invoice.discountAmount && invoice.discountAmount > 0) && (
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
@@ -307,7 +301,7 @@ const isDraft = invoice.status === 'draft';
Discount Discount
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`} {invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
</span> </span>
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span> <span class="font-medium text-success">-{formatCurrency(invoice.discountAmount, invoice.currency)}</span>
</div> </div>
)} )}
{((invoice.taxRate ?? 0) > 0 || isDraft) && ( {((invoice.taxRate ?? 0) > 0 || isDraft) && (
@@ -320,13 +314,13 @@ const isDraft = invoice.status === 'draft';
</button> </button>
)} )}
</span> </span>
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span> <span class="font-medium">{formatCurrency(invoice.taxAmount, invoice.currency)}</span>
</div> </div>
)} )}
<div class="divider my-2"></div> <div class="divider my-2"></div>
<div class="flex justify-between text-lg font-bold"> <div class="flex justify-between text-lg font-bold">
<span>Total</span> <span>Total</span>
<span class="text-primary">{formatCurrency(invoice.total)}</span> <span class="text-primary">{formatCurrency(invoice.total, invoice.currency)}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,27 +1,18 @@
--- ---
import DashboardLayout from '../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import StatCard from '../../../components/StatCard.astro';
import { db } from '../../../db'; import { db } from '../../../db';
import { invoices, clients, members } from '../../../db/schema'; import { invoices, clients } from '../../../db/schema';
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm'; import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
import { formatCurrency } from '../../../lib/formatTime';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const currentTeamIdResolved = userMembership.organizationId; const currentTeamIdResolved = userMembership.organizationId;
@@ -96,13 +87,6 @@ const yearInvoices = allInvoicesRaw.filter(i => {
return issueDate >= yearStart && issueDate <= yearEnd; return issueDate >= yearStart && issueDate <= yearEnd;
}); });
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
};
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'paid': return 'badge-success'; case 'paid': return 'badge-success';
@@ -130,40 +114,35 @@ const getStatusColor = (status: string) => {
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="stats shadow bg-base-100 border border-base-200"> <div class="stats shadow bg-base-100 border border-base-200">
<div class="stat"> <StatCard
<div class="stat-figure text-primary"> title="Total Invoices"
<Icon name="heroicons:document-text" class="w-8 h-8" /> value={String(yearInvoices.filter(i => i.invoice.type === 'invoice').length)}
</div> description={selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}
<div class="stat-title">Total Invoices</div> icon="heroicons:document-text"
<div class="stat-value text-primary">{yearInvoices.filter(i => i.invoice.type === 'invoice').length}</div> color="text-primary"
<div class="stat-desc">{selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}</div> />
</div>
</div> </div>
<div class="stats shadow bg-base-100 border border-base-200"> <div class="stats shadow bg-base-100 border border-base-200">
<div class="stat"> <StatCard
<div class="stat-figure text-secondary"> title="Open Quotes"
<Icon name="heroicons:clipboard-document-list" class="w-8 h-8" /> value={String(yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length)}
</div> description="Waiting for approval"
<div class="stat-title">Open Quotes</div> icon="heroicons:clipboard-document-list"
<div class="stat-value text-secondary">{yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div> color="text-secondary"
<div class="stat-desc">Waiting for approval</div> />
</div>
</div> </div>
<div class="stats shadow bg-base-100 border border-base-200"> <div class="stats shadow bg-base-100 border border-base-200">
<div class="stat"> <StatCard
<div class="stat-figure text-success"> title="Total Revenue"
<Icon name="heroicons:currency-dollar" class="w-8 h-8" /> value={formatCurrency(yearInvoices
</div> .filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
<div class="stat-title">Total Revenue</div> .reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
<div class="stat-value text-success"> description={`Paid invoices (${selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})`}
{formatCurrency(yearInvoices icon="heroicons:currency-dollar"
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid') color="text-success"
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')} />
</div>
<div class="stat-desc">Paid invoices ({selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})</div>
</div>
</div> </div>
</div> </div>

View File

@@ -2,26 +2,15 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { db } from '../../../db'; import { db } from '../../../db';
import { clients, members, invoices, organizations } from '../../../db/schema'; import { clients, invoices, organizations } from '../../../db/schema';
import { eq, desc, and } from 'drizzle-orm'; import { eq, desc, and } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const currentTeamIdResolved = userMembership.organizationId; const currentTeamIdResolved = userMembership.organizationId;

View File

@@ -1,31 +1,21 @@
--- ---
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import StatCard from '../../components/StatCard.astro';
import TagChart from '../../components/TagChart.vue'; import TagChart from '../../components/TagChart.vue';
import ClientChart from '../../components/ClientChart.vue'; import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue'; import MemberChart from '../../components/MemberChart.vue';
import { db } from '../../db'; import { db } from '../../db';
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema'; import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm'; import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
import { formatDuration, formatTimeRange } from '../../lib/formatTime'; import { formatDuration, formatTimeRange, formatCurrency } from '../../lib/formatTime';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const teamMembers = await db.select({ const teamMembers = await db.select({
id: users.id, id: users.id,
@@ -247,13 +237,6 @@ const revenueByClient = allClients.map(client => {
}; };
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue); }).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
function formatCurrency(amount: number, currency: string = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
}
function getTimeRangeLabel(range: string) { function getTimeRangeLabel(range: string) {
switch (range) { switch (range) {
case 'today': return 'Today'; case 'today': return 'Today';
@@ -383,46 +366,44 @@ function getTimeRangeLabel(range: string) {
<!-- Summary Stats --> <!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="stats shadow border border-base-300"> <div class="stats shadow border border-base-300">
<div class="stat"> <StatCard
<div class="stat-figure text-primary"> title="Total Time"
<Icon name="heroicons:clock" class="w-8 h-8" /> value={formatDuration(totalTime)}
</div> description={getTimeRangeLabel(timeRange)}
<div class="stat-title">Total Time</div> icon="heroicons:clock"
<div class="stat-value text-primary">{formatDuration(totalTime)}</div> color="text-primary"
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div> />
</div>
</div> </div>
<div class="stats shadow border border-base-300"> <div class="stats shadow border border-base-300">
<div class="stat"> <StatCard
<div class="stat-figure text-secondary"> title="Total Entries"
<Icon name="heroicons:list-bullet" class="w-8 h-8" /> value={String(entries.length)}
</div> description={getTimeRangeLabel(timeRange)}
<div class="stat-title">Total Entries</div> icon="heroicons:list-bullet"
<div class="stat-value text-secondary">{entries.length}</div> color="text-secondary"
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div> />
</div>
</div> </div>
<div class="stats shadow border border-base-300"> <div class="stats shadow border border-base-300">
<div class="stat"> <StatCard
<div class="stat-figure text-success"> title="Revenue"
<Icon name="heroicons:currency-dollar" class="w-8 h-8" /> value={formatCurrency(revenueStats.total)}
</div> description={`${invoiceStats.paid} paid invoices`}
<div class="stat-title">Revenue</div> icon="heroicons:currency-dollar"
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div> color="text-success"
<div class="stat-desc">{invoiceStats.paid} paid invoices</div> />
</div>
</div> </div>
<div class="stats shadow border border-base-300"> <div class="stats shadow border border-base-300">
<div class="stat"> <StatCard
<div class="stat-figure text-accent"> title="Active Members"
<Icon name="heroicons:user-group" class="w-8 h-8" /> value={String(statsByMember.filter(s => s.entryCount > 0).length)}
</div> description={`of ${teamMembers.length} total`}
<div class="stat-title">Active Members</div> icon="heroicons:user-group"
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div> color="text-accent"
<div class="stat-desc">of {teamMembers.length} total</div> />
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -50,10 +50,10 @@ const userPasskeys = await db.select()
)} )}
<!-- Profile Information --> <!-- Profile Information -->
<ProfileForm client:load user={user} /> <ProfileForm client:idle user={user} />
<!-- Change Password --> <!-- Change Password -->
<PasswordForm client:load /> <PasswordForm client:idle />
<!-- Passkeys --> <!-- Passkeys -->
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({ <PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({

View File

@@ -5,24 +5,13 @@ import { Icon } from 'astro-icon/components';
import { db } from '../../db'; import { db } from '../../db';
import { members, users } from '../../db/schema'; import { members, users } from '../../db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const teamMembers = await db.select({ const teamMembers = await db.select({
member: members, member: members,

View File

@@ -2,26 +2,15 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { db } from '../../../db'; import { db } from '../../../db';
import { members, organizations, tags } from '../../../db/schema'; import { organizations, tags } from '../../../db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin'; const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team'); if (!isAdmin) return Astro.redirect('/dashboard/team');

View File

@@ -4,27 +4,16 @@ import { Icon } from 'astro-icon/components';
import Timer from '../../components/Timer.vue'; import Timer from '../../components/Timer.vue';
import ManualEntry from '../../components/ManualEntry.vue'; import ManualEntry from '../../components/ManualEntry.vue';
import { db } from '../../db'; import { db } from '../../db';
import { timeEntries, clients, members, tags, users } from '../../db/schema'; import { timeEntries, clients, tags, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm'; import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
import { formatTimeRange } from '../../lib/formatTime'; import { formatTimeRange } from '../../lib/formatTime';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const organizationId = userMembership.organizationId; const organizationId = userMembership.organizationId;
@@ -153,12 +142,12 @@ const paginationPages = getPaginationPages(page, totalPages);
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1> <h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
<!-- Tabs for Timer and Manual Entry --> <!-- Tabs for Timer and Manual Entry -->
<div role="tablist" class="tabs tabs-lifted mb-6"> <div class="tabs tabs-lift mb-6">
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked /> <input type="radio" name="tracker_tabs" class="tab" aria-label="Timer" checked="checked" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6"> <div class="tab-content bg-base-100 border-base-300 p-6">
{allClients.length === 0 ? ( {allClients.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4"> <div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> <Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span> <span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a> <a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div> </div>
@@ -177,11 +166,11 @@ const paginationPages = getPaginationPages(page, totalPages);
)} )}
</div> </div>
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" /> <input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6"> <div class="tab-content bg-base-100 border-base-300 p-6">
{allClients.length === 0 ? ( {allClients.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4"> <div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> <Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span> <span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a> <a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div> </div>

View File

@@ -52,10 +52,8 @@ export const GET: APIRoute = async ({ params }) => {
case ".gif": case ".gif":
contentType = "image/gif"; contentType = "image/gif";
break; break;
case ".svg": // SVG excluded to prevent stored XSS
contentType = "image/svg+xml"; // WebP omitted — not supported in PDF generation
break;
// WebP is intentionally omitted as it is not supported in PDF generation
} }
return new Response(fileContent, { return new Response(fileContent, {