13 Commits

Author SHA1 Message Date
76d3e0cd41 Deps + Bun
All checks were successful
Docker Deploy / build-and-push (push) Successful in 6m9s
2026-03-03 13:30:43 -07:00
42492be284 2.5.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m7s
2026-02-14 01:51:57 -07:00
cd6ececa27 Improved logo 2026-02-14 01:51:43 -07:00
be5dafe539 Updated the logo
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m50s
2026-02-14 01:47:14 -07:00
6233380682 Cleaned up the theme a bit 2026-02-14 01:11:01 -07:00
e99e042eea Fixed Origin mismatch for passkeys
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m9s
2026-02-13 11:35:06 -07:00
705358d44c Please...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m55s
2026-02-13 11:18:20 -07:00
44de064d68 Attempted fix for auth
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m56s
2026-02-13 10:55:35 -07:00
5f7b36582c Fixed
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m30s
2026-02-12 23:04:27 -07:00
25c9d77599 Oops CRF was too strong.
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m19s
2026-02-12 16:31:36 -07:00
3e17e58c9a Strengthened CRF, added more vue, and removed viewtransitions
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m42s
2026-02-12 16:19:59 -07:00
e5c5d68739 Fix logout bug with viewtransitions
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m24s
2026-02-12 15:31:28 -07:00
c7d880e09d Docker optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m0s
2026-02-12 15:11:54 -07:00
66 changed files with 2286 additions and 7959 deletions

View File

@@ -2,3 +2,5 @@ DATA_DIR=./data
ROOT_DIR=./data ROOT_DIR=./data
APP_PORT=4321 APP_PORT=4321
IMAGE=git.atri.dad/atash/chronus:latest IMAGE=git.atri.dad/atash/chronus:latest
JWT_SECRET=some-secret
ORIGIN=https://chronus.example.com

View File

@@ -12,20 +12,20 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Login to Container Registry - name: Login to Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ${{ secrets.REPO_HOST }} registry: ${{ secrets.REPO_HOST }}
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.DEPLOY_TOKEN }} password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
@@ -33,3 +33,6 @@ jobs:
tags: | tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }} ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
provenance: false
cache-from: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache
cache-to: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache,mode=max

View File

@@ -1,35 +1,28 @@
FROM node:lts-alpine AS base FROM oven/bun:1.3.9-alpine AS base
WORKDIR /app WORKDIR /app
RUN npm i -g pnpm
FROM base AS prod-deps FROM base AS prod-deps
WORKDIR /app COPY package.json bun.lock ./
RUN apk add --no-cache python3 make g++ RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
COPY package.json pnpm-lock.yaml ./ bun install --production --frozen-lockfile || bun install --production
RUN pnpm install --prod --frozen-lockfile
FROM base AS build-deps FROM base AS builder
WORKDIR /app COPY package.json bun.lock ./
RUN apk add --no-cache python3 make g++ RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
COPY package.json pnpm-lock.yaml ./ bun install --frozen-lockfile || bun install
RUN pnpm install --frozen-lockfile
FROM build-deps AS builder
WORKDIR /app
COPY . . COPY . .
RUN pnpm run build RUN bun run build
FROM base AS runtime FROM base AS runtime
WORKDIR /app WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/drizzle ./drizzle COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/scripts ./scripts
COPY package.json ./ COPY package.json ./
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=4321 ENV PORT=4321
EXPOSE 4321 EXPOSE 4321
CMD ["sh", "-c", "npm run migrate && node ./dist/server/entry.mjs"] CMD ["bun", "run", "./dist/server/entry.mjs"]

View File

@@ -9,6 +9,7 @@ export default defineConfig({
output: "server", output: "server",
integrations: [vue()], integrations: [vue()],
security: { security: {
checkOrigin: false,
csp: process.env.NODE_ENV === "production", csp: process.env.NODE_ENV === "production",
}, },
vite: { vite: {

1715
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ services:
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=4321 - PORT=4321
- DATA_DIR=/app/data - DATA_DIR=/app/data
- JWT_SECRET=${JWT_SECRET}
- ORIGIN=${ORIGIN}
volumes: volumes:
- ${ROOT_DIR}:/app/data - ${ROOT_DIR}:/app/data
restart: unless-stopped restart: unless-stopped

View File

@@ -5,7 +5,8 @@
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11"; nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
}; };
outputs = { self, nixpkgs }: outputs =
{ nixpkgs }:
let let
allSystems = [ allSystems = [
"x86_64-linux" "x86_64-linux"
@@ -14,25 +15,33 @@
"aarch64-darwin" "aarch64-darwin"
]; ];
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f { forAllSystems =
pkgs = import nixpkgs { inherit system; }; f:
}); nixpkgs.lib.genAttrs allSystems (
system:
f {
pkgs = import nixpkgs { inherit system; };
}
);
in in
{ {
devShells = forAllSystems ({ pkgs }: { devShells = forAllSystems (
default = pkgs.mkShell { { pkgs }:
packages = with pkgs; [ {
nodejs_24 default = pkgs.mkShell {
nodePackages.pnpm packages = with pkgs; [
sqlite nodejs_24
]; nodePackages.pnpm
sqlite
];
shellHook = '' shellHook = ''
echo "Chronus dev shell" echo "Chronus dev shell"
echo "Node version: $(node --version)" echo "Node version: $(node --version)"
echo "pnpm version: $(pnpm --version)" echo "pnpm version: $(pnpm --version)"
''; '';
}; };
}); }
);
}; };
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "chronus", "name": "chronus",
"type": "module", "type": "module",
"version": "2.4.0", "version": "2.5.0",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
@@ -13,28 +13,29 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "0.9.6", "@astrojs/check": "0.9.6",
"@astrojs/node": "10.0.0-beta.4", "@astrojs/node": "9.5.4",
"@astrojs/vue": "6.0.0-beta.1", "@astrojs/vue": "5.1.4",
"@ceereals/vue-pdf": "^0.2.1", "@ceereals/vue-pdf": "^0.2.1",
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.3",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.2.1",
"astro": "6.0.0-beta.11", "astro": "5.18.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"daisyui": "^5.5.18", "daisyui": "^5.5.19",
"dotenv": "^17.3.0", "dotenv": "^17.3.1",
"drizzle-orm": "0.45.1", "drizzle-orm": "0.45.1",
"jsonwebtoken": "^9.0.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.2.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vue": "^3.5.28", "vue": "^3.5.29",
"vue-chartjs": "^5.3.3" "vue-chartjs": "^5.3.3"
}, },
"devDependencies": { "devDependencies": {
"@catppuccin/daisyui": "^2.1.1",
"@react-pdf/types": "^2.9.2", "@react-pdf/types": "^2.9.2",
"@types/jsonwebtoken": "^9.0.10",
"drizzle-kit": "0.31.9" "drizzle-kit": "0.31.9"
} }
} }

7489
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
function onChange(e: Event) {
const el = e.target as HTMLElement;
el.closest('form')?.submit();
}
</script>
<template>
<span @change="onChange">
<slot />
</span>
</template>

View File

@@ -9,7 +9,7 @@ const initial = name ? name.charAt(0).toUpperCase() : '?';
--- ---
<div class:list={["avatar placeholder", className]}> <div class:list={["avatar placeholder", className]}>
<div class="bg-primary/15 text-primary w-9 h-9 rounded-full flex items-center justify-center"> <div class="bg-base-300 text-primary w-9 h-9 rounded-full flex items-center justify-center">
<span class="text-sm font-semibold">{initial}</span> <span class="text-sm font-semibold">{initial}</span>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{
color: string;
as?: string;
class?: string;
borderColor?: boolean;
}>();
</script>
<template>
<component
:is="as || 'span'"
:class="$props.class"
:style="borderColor ? { borderColor: color } : { backgroundColor: color }"
><slot /></component>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{
message: string;
action: string;
method?: string;
class?: string;
}>();
function onSubmit(e: Event) {
if (!confirm((e.currentTarget as HTMLFormElement).dataset.message!)) {
e.preventDefault();
}
}
</script>
<template>
<form
:method="method || 'POST'"
:action="action"
:class="$props.class"
:data-message="message"
@submit="onSubmit"
>
<slot />
</form>
</template>

View File

@@ -148,7 +148,7 @@ function clearForm() {
<template> <template>
<div <div
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200" class="card bg-base-200 backdrop-blur-sm shadow-lg border border-base-content/20 hover:border-base-content/20 transition-all duration-200"
> >
<div class="card-body gap-6"> <div class="card-body gap-6">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@@ -183,7 +183,7 @@ function clearForm() {
<select <select
id="manual-client" id="manual-client"
v-model="selectedClientId" v-model="selectedClientId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" class="select select-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting" :disabled="isSubmitting"
> >
<option value="">Select a client...</option> <option value="">Select a client...</option>
@@ -203,7 +203,7 @@ function clearForm() {
id="manual-start-date" id="manual-start-date"
v-model="startDate" v-model="startDate"
type="date" type="date"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting" :disabled="isSubmitting"
/> />
</div> </div>
@@ -216,7 +216,7 @@ function clearForm() {
id="manual-start-time" id="manual-start-time"
v-model="startTime" v-model="startTime"
type="time" type="time"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting" :disabled="isSubmitting"
/> />
</div> </div>
@@ -232,7 +232,7 @@ function clearForm() {
id="manual-end-date" id="manual-end-date"
v-model="endDate" v-model="endDate"
type="date" type="date"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting" :disabled="isSubmitting"
/> />
</div> </div>
@@ -245,7 +245,7 @@ function clearForm() {
id="manual-end-time" id="manual-end-time"
v-model="endTime" v-model="endTime"
type="time" type="time"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting" :disabled="isSubmitting"
/> />
</div> </div>
@@ -261,7 +261,7 @@ function clearForm() {
v-model="description" v-model="description"
type="text" type="text"
placeholder="What did you work on?" placeholder="What did you work on?"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting" :disabled="isSubmitting"
/> />
</div> </div>
@@ -278,7 +278,7 @@ function clearForm() {
'badge badge-lg cursor-pointer transition-all hover:scale-105', 'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTagId === tag.id selectedTagId === tag.id
? 'badge-primary shadow-lg shadow-primary/20' ? 'badge-primary shadow-lg shadow-primary/20'
: 'badge-outline hover:bg-base-300/50', : 'badge-outline hover:bg-base-300',
]" ]"
:disabled="isSubmitting" :disabled="isSubmitting"
type="button" type="button"

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{
modalId: string;
action?: 'open' | 'close';
class?: string;
title?: string;
type?: string;
}>();
function onClick(e: MouseEvent) {
const btn = e.currentTarget as HTMLElement;
const id = btn.dataset.modalId!;
const act = btn.dataset.action || 'open';
const modal = document.getElementById(id) as HTMLDialogElement | null;
if (act === 'close') {
modal?.close();
} else {
modal?.showModal();
}
}
</script>
<template>
<button
:type="(type as any) || 'button'"
:class="$props.class"
:title="$props.title"
:data-modal-id="modalId"
:data-action="action || 'open'"
@click="onClick"
>
<slot />
</button>
</template>

View File

@@ -18,12 +18,12 @@ const { title, value, description, icon, color = 'text-primary', valueClass } =
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wider text-base-content/60">{title}</span> <span class="text-xs font-medium uppercase tracking-wider text-base-content/60">{title}</span>
{icon && ( {icon && (
<div class:list={[color, "opacity-40"]}> <div class:list={[color, "opacity-70"]}>
<Icon name={icon} class="w-5 h-5" /> <Icon name={icon} class="w-5 h-5" />
</div> </div>
)} )}
</div> </div>
<div class:list={["text-2xl font-bold", color, valueClass]}>{value}</div> <div class:list={["text-2xl font-bold", color, valueClass]}>{value}</div>
{description && <div class="text-xs text-base-content/50">{description}</div>} {description && <div class="text-xs text-base-content/60">{description}</div>}
</div> </div>
</div> </div>

View File

@@ -1,34 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from "vue";
import Icon from './Icon.vue'; import Icon from "./Icon.vue";
const theme = ref('macchiato'); const theme = ref("sunset");
onMounted(() => { onMounted(() => {
const stored = localStorage.getItem('theme'); const stored = localStorage.getItem("theme");
if (stored) { if (stored) {
theme.value = stored; theme.value = stored;
document.documentElement.setAttribute('data-theme', stored); document.documentElement.setAttribute("data-theme", stored);
} }
}); });
function toggleTheme() { function toggleTheme() {
const newTheme = theme.value === 'macchiato' ? 'latte' : 'macchiato'; const newTheme = theme.value === "sunset" ? "winter" : "sunset";
theme.value = newTheme; theme.value = newTheme;
document.documentElement.setAttribute('data-theme', newTheme); document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem('theme', newTheme); localStorage.setItem("theme", newTheme);
} }
</script> </script>
<template> <template>
<button <button
@click="toggleTheme" @click="toggleTheme"
class="btn btn-ghost btn-circle" class="btn btn-ghost btn-circle"
aria-label="Toggle Theme" aria-label="Toggle Theme"
> >
<Icon <Icon :name="theme === 'sunset' ? 'moon' : 'sun'" class="w-5 h-5" />
:name="theme === 'macchiato' ? 'moon' : 'sun'"
class="w-5 h-5"
/>
</button> </button>
</template> </template>

View File

@@ -118,7 +118,7 @@ async function stopTimer() {
<template> <template>
<div <div
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 mb-6 hover:border-base-300 transition-all duration-200" class="card bg-base-200 backdrop-blur-sm shadow-lg border border-base-content/20 mb-6 hover:border-base-content/20 transition-all duration-200"
> >
<div class="card-body gap-6"> <div class="card-body gap-6">
<!-- Client Row --> <!-- Client Row -->
@@ -129,7 +129,7 @@ async function stopTimer() {
<select <select
id="timer-client" id="timer-client"
v-model="selectedClientId" v-model="selectedClientId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" class="select select-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isRunning" :disabled="isRunning"
> >
<option value="">Select a client...</option> <option value="">Select a client...</option>
@@ -149,7 +149,7 @@ async function stopTimer() {
v-model="description" v-model="description"
type="text" type="text"
placeholder="What are you working on?" placeholder="What are you working on?"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isRunning" :disabled="isRunning"
/> />
</div> </div>
@@ -166,7 +166,7 @@ async function stopTimer() {
'badge badge-lg cursor-pointer transition-all hover:scale-105', 'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTagId === tag.id selectedTagId === tag.id
? 'badge-primary shadow-lg shadow-primary/20' ? 'badge-primary shadow-lg shadow-primary/20'
: 'badge-outline hover:bg-base-300/50', : 'badge-outline hover:bg-base-300',
]" ]"
:disabled="isRunning" :disabled="isRunning"
type="button" type="button"

View File

@@ -107,7 +107,7 @@ function closeShowTokenModal() {
<template> <template>
<div> <div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card bg-base-100 shadow-xl border border-base-content/20 mb-6">
<div class="card-body p-4 sm:p-6"> <div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl"> <h2 class="card-title text-lg sm:text-xl">

View File

@@ -94,7 +94,7 @@ async function deletePasskey(id: string) {
</script> </script>
<template> <template>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card bg-base-100 shadow-xl border border-base-content/20 mb-6">
<div class="card-body p-4 sm:p-6"> <div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl"> <h2 class="card-title text-lg sm:text-xl">

View File

@@ -86,7 +86,7 @@ async function changePassword() {
<span>{{ message.text }}</span> <span>{{ message.text }}</span>
</div> </div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card bg-base-100 shadow-xl border border-base-content/20 mb-6">
<div class="card-body p-4 sm:p-6"> <div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl"> <h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon name="key" class="w-5 h-5 sm:w-6 sm:h-6" /> <Icon name="key" class="w-5 h-5 sm:w-6 sm:h-6" />

View File

@@ -71,7 +71,7 @@ async function updateProfile() {
<span>{{ message.text }}</span> <span>{{ message.text }}</span>
</div> </div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card bg-base-100 shadow-xl border border-base-content/20 mb-6">
<div class="card-body p-4 sm:p-6"> <div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl"> <h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon name="user-circle" class="w-5 h-5 sm:w-6 sm:h-6" /> <Icon name="user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />

1
src/env.d.ts vendored
View File

@@ -14,7 +14,6 @@ interface ImportMeta {
declare namespace App { declare namespace App {
interface Locals { interface Locals {
user: import("./db/schema").User | null; user: import("./db/schema").User | null;
session: import("./db/schema").Session | null;
scopes: string[] | null; scopes: string[] | null;
} }
} }

View File

@@ -6,7 +6,6 @@ import { members, organizations } from '../db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import Avatar from '../components/Avatar.astro'; import Avatar from '../components/Avatar.astro';
import ThemeToggle from '../components/ThemeToggle.vue'; import ThemeToggle from '../components/ThemeToggle.vue';
import { ClientRouter } from "astro:transitions";
interface Props { interface Props {
title: string; title: string;
@@ -29,7 +28,6 @@ const userMemberships = await db.select({
.all(); .all();
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id; const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
const navItems = [ const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: 'home', exact: true }, { href: '/dashboard', label: 'Dashboard', icon: 'home', exact: true },
@@ -55,9 +53,8 @@ function isActive(item: { href: string; exact?: boolean }) {
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{title}</title> <title>{title}</title>
<ClientRouter />
<script is:inline> <script is:inline>
const theme = localStorage.getItem('theme') || 'macchiato'; const theme = localStorage.getItem('theme') || 'sunset';
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
</script> </script>
</head> </head>
@@ -66,7 +63,7 @@ function isActive(item: { href: string; exact?: boolean }) {
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" /> <input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col h-full overflow-auto"> <div class="drawer-content flex flex-col h-full overflow-auto">
<!-- Mobile Navbar --> <!-- Mobile Navbar -->
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-200"> <div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-content/20">
<div class="flex-none"> <div class="flex-none">
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost btn-sm"> <label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost btn-sm">
<Icon name="bars-3" class="w-5 h-5" /> <Icon name="bars-3" class="w-5 h-5" />
@@ -89,7 +86,7 @@ function isActive(item: { href: string; exact?: boolean }) {
<div class="drawer-side z-50"> <div class="drawer-side z-50">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label> <label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<aside class="bg-base-200 min-h-full w-72 flex flex-col border-r border-base-300/40"> <aside class="bg-base-200 min-h-full w-72 flex flex-col border-r border-base-content/20">
<!-- Logo --> <!-- Logo -->
<div class="px-5 pt-5 pb-3"> <div class="px-5 pt-5 pb-3">
<a href="/dashboard" class="flex items-center gap-2.5 group"> <a href="/dashboard" class="flex items-center gap-2.5 group">
@@ -102,10 +99,9 @@ function isActive(item: { href: string; exact?: boolean }) {
{userMemberships.length > 0 && ( {userMemberships.length > 0 && (
<div class="px-4 pb-2"> <div class="px-4 pb-2">
<select <select
class="select select-sm w-full bg-base-300/40 border-base-300/60 focus:border-primary/50 focus:outline-none text-sm font-medium" class="select select-sm w-full bg-base-300 border-base-content/20 focus:border-primary focus:outline-none text-sm font-medium"
id="team-switcher" id="team-switcher"
aria-label="Switch team" aria-label="Switch team"
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
> >
{userMemberships.map(({ membership, organization }) => ( {userMemberships.map(({ membership, organization }) => (
<option <option
@@ -138,8 +134,8 @@ function isActive(item: { href: string; exact?: boolean }) {
<a href={item.href} class:list={[ <a href={item.href} class:list={[
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm", "rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
isActive(item) isActive(item)
? "bg-primary/10 text-primary" ? "bg-primary text-primary-content"
: "text-base-content/70 hover:text-base-content hover:bg-base-300/50" : "text-base-content/70 hover:text-base-content hover:bg-base-300"
]}> ]}>
<Icon name={item.icon} class="w-[18px] h-[18px]" /> <Icon name={item.icon} class="w-[18px] h-[18px]" />
{item.label} {item.label}
@@ -156,8 +152,8 @@ function isActive(item: { href: string; exact?: boolean }) {
<a href="/admin" class:list={[ <a href="/admin" class:list={[
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm", "rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
Astro.url.pathname.startsWith("/admin") Astro.url.pathname.startsWith("/admin")
? "bg-primary/10 text-primary" ? "bg-primary text-primary-content"
: "text-base-content/70 hover:text-base-content hover:bg-base-300/50" : "text-base-content/70 hover:text-base-content hover:bg-base-300"
]}> ]}>
<Icon name="cog-6-tooth" class="w-[18px] h-[18px]" /> <Icon name="cog-6-tooth" class="w-[18px] h-[18px]" />
Site Admin Site Admin
@@ -169,34 +165,45 @@ function isActive(item: { href: string; exact?: boolean }) {
</nav> </nav>
<!-- Bottom Section --> <!-- Bottom Section -->
<div class="mt-auto border-t border-base-300/40"> <div class="mt-auto border-t border-base-content/20">
<div class="p-3"> <div class="p-3">
<a href="/dashboard/settings" class="flex items-center gap-3 rounded-lg p-2.5 hover:bg-base-300/40 group"> <a href="/dashboard/settings" class="flex items-center gap-3 rounded-lg p-2.5 hover:bg-base-300 group">
<Avatar name={user.name} /> <Avatar name={user.name} />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{user.name}</div> <div class="font-medium text-sm truncate">{user.name}</div>
<div class="text-xs text-base-content/50 truncate">{user.email}</div> <div class="text-xs text-base-content/60 truncate">{user.email}</div>
</div> </div>
<Icon name="chevron-right" class="w-4 h-4 text-base-content/30 group-hover:text-base-content/50" /> <Icon name="chevron-right" class="w-4 h-4 text-base-content/50 group-hover:text-base-content/70" />
</a> </a>
</div> </div>
<div class="flex items-center justify-between px-5 pb-2"> <div class="flex items-center justify-between px-5 pb-2">
<span class="text-xs text-base-content/40 font-medium">Theme</span> <span class="text-xs text-base-content/60 font-medium">Theme</span>
<ThemeToggle client:load /> <ThemeToggle client:load />
</div> </div>
<div class="px-3 pb-3"> <div class="px-3 pb-3">
<form action="/api/auth/logout" method="POST"> <button id="logout-btn" type="button" class="btn btn-ghost btn-sm btn-block justify-start gap-2 text-base-content/60 hover:text-error hover:bg-base-300 font-medium">
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start gap-2 text-base-content/60 hover:text-error hover:bg-error/10 font-medium"> <Icon name="arrow-right-on-rectangle" class="w-[18px] h-[18px]" />
<Icon name="arrow-right-on-rectangle" class="w-[18px] h-[18px]" /> Logout
Logout </button>
</button>
</form>
</div> </div>
</div> </div>
</aside> </aside>
</div> </div>
</div> </div>
<script>
const teamSwitcher = document.getElementById('team-switcher') as HTMLSelectElement | null;
teamSwitcher?.addEventListener('change', () => {
document.cookie = 'currentTeamId=' + teamSwitcher.value + '; path=/';
window.location.reload();
});
const logoutBtn = document.getElementById('logout-btn');
logoutBtn?.addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.reload();
});
</script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,5 @@
--- ---
import '../styles/global.css'; import '../styles/global.css';
import { ClientRouter } from "astro:transitions";
interface Props { interface Props {
title: string; title: string;
@@ -18,9 +17,8 @@ const { title } = Astro.props;
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{title}</title> <title>{title}</title>
<ClientRouter />
<script is:inline> <script is:inline>
const theme = localStorage.getItem('theme') || 'macchiato'; const theme = localStorage.getItem('theme') || 'sunset';
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
</script> </script>
</head> </head>

View File

@@ -1,50 +1,74 @@
import { db } from '../db'; import { db } from "../db";
import { users, sessions } from '../db/schema'; import { users } from "../db/schema";
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import bcrypt from 'bcryptjs'; import bcrypt from "bcryptjs";
import { nanoid } from 'nanoid'; import jwt from "jsonwebtoken";
import type { AstroCookies } from "astro";
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days const JWT_SECRET =
process.env.JWT_SECRET || "chronus-dev-secret-change-in-production";
const JWT_EXPIRES_IN = "30d";
export async function createSession(userId: string) { interface JwtPayload {
const sessionId = nanoid(); userId: string;
const expiresAt = new Date(Date.now() + SESSION_DURATION); }
await db.insert(sessions).values({ export function createToken(userId: string): string {
id: sessionId, return jwt.sign({ userId } satisfies JwtPayload, JWT_SECRET, {
userId, expiresIn: JWT_EXPIRES_IN,
expiresAt,
}); });
return { sessionId, expiresAt };
} }
export async function validateSession(sessionId: string) { export function verifyToken(token: string): JwtPayload | null {
const result = await db.select({ try {
user: users, return jwt.verify(token, JWT_SECRET) as JwtPayload;
session: sessions } catch {
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, sessionId))
.get();
if (!result) {
return null; return null;
} }
const { session, user } = result;
if (Date.now() >= session.expiresAt.getTime()) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
return null;
}
return { session, user };
} }
export async function invalidateSession(sessionId: string) { export function setAuthCookie(cookies: AstroCookies, userId: string) {
await db.delete(sessions).where(eq(sessions.id, sessionId)); const token = createToken(userId);
cookies.set("auth_token", token, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30,
});
}
export function clearAuthCookie(cookies: AstroCookies) {
cookies.delete("auth_token", { path: "/" });
}
export async function getUserFromToken(token: string) {
const payload = verifyToken(token);
if (!payload) return null;
const user = await db
.select()
.from(users)
.where(eq(users.id, payload.userId))
.get();
return user ?? null;
}
/**
* Get the public origin and hostname from the ORIGIN environment variable.
* This is required for WebAuthn/passkey rpID to match the browser's origin.
*/
export function getOrigin(): { hostname: string; origin: string } {
const origin = process.env.ORIGIN;
if (!origin) {
throw new Error("ORIGIN environment variable is not set");
}
const url = new URL(origin);
return {
hostname: url.hostname,
origin: url.origin,
};
} }
export async function hashPassword(password: string) { export async function hashPassword(password: string) {

View File

@@ -1,8 +1,12 @@
import { defineMiddleware } from "astro/middleware"; import { defineMiddleware } from "astro/middleware";
import { validateSession } from "./lib/auth"; import { getUserFromToken } from "./lib/auth";
import { validateApiToken } from "./lib/api-auth"; import { validateApiToken } from "./lib/api-auth";
const PUBLIC_ROUTES = ["/", "/login", "/signup"];
export const onRequest = defineMiddleware(async (context, next) => { export const onRequest = defineMiddleware(async (context, next) => {
const { pathname } = context.url;
const authHeader = context.request.headers.get("Authorization"); const authHeader = context.request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) { if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring(7); const token = authHeader.substring(7);
@@ -10,32 +14,26 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (result) { if (result) {
context.locals.user = result.user; context.locals.user = result.user;
context.locals.session = null;
context.locals.scopes = result.scopes; context.locals.scopes = result.scopes;
return next(); return next();
} }
} }
const sessionId = context.cookies.get("session_id")?.value; const token = context.cookies.get("auth_token")?.value;
if (!sessionId) { if (token) {
context.locals.user = null; const user = await getUserFromToken(token);
context.locals.session = null; context.locals.user = user;
context.locals.scopes = null;
return next();
}
const result = await validateSession(sessionId);
if (result) {
context.locals.user = result.user;
context.locals.session = result.session;
context.locals.scopes = null;
} else { } else {
context.locals.user = null; context.locals.user = null;
context.locals.session = null; }
context.locals.scopes = null; context.locals.scopes = null;
context.cookies.delete("session_id");
const isPublic =
PUBLIC_ROUTES.includes(pathname) || pathname.startsWith("/api/");
if (!isPublic && !context.locals.user) {
return context.redirect("/login");
} }
return next(); return next();

View File

@@ -32,7 +32,7 @@ const allUsers = await db.select().from(users).all();
title="Total Users" title="Total Users"
value={String(allUsers.length)} value={String(allUsers.length)}
description="Registered accounts" description="Registered accounts"
icon="heroicons:users" icon="users"
color="text-primary" color="text-primary"
/> />
</div> </div>
@@ -63,7 +63,7 @@ const allUsers = await db.select().from(users).all();
<!-- Users List --> <!-- Users List -->
<div class="card card-border bg-base-100"> <div class="card card-border bg-base-100">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="px-4 py-3 border-b border-base-200"> <div class="px-4 py-3 border-b border-base-content/20">
<h2 class="text-sm font-semibold">All Users</h2> <h2 class="text-sm font-semibold">All Users</h2>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -93,7 +93,7 @@ const allUsers = await db.select().from(users).all();
<span class="badge badge-xs badge-ghost">No</span> <span class="badge badge-xs badge-ghost">No</span>
)} )}
</td> </td>
<td class="text-base-content/40">{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td> <td class="text-base-content/60">{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -1,7 +1,7 @@
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 } from "../../../db/schema";
import { verifyPassword, createSession } from "../../../lib/auth"; import { verifyPassword, setAuthCookie } from "../../../lib/auth";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
export const POST: APIRoute = async ({ request, cookies, redirect }) => { export const POST: APIRoute = async ({ request, cookies, redirect }) => {
@@ -23,15 +23,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
return redirect("/login?error=invalid_credentials"); return redirect("/login?error=invalid_credentials");
} }
const { sessionId, expiresAt } = await createSession(user.id); setAuthCookie(cookies, user.id);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
return redirect("/dashboard"); return redirect("/dashboard");
}; };

View File

@@ -1,11 +1,7 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { invalidateSession } from '../../../lib/auth'; import { clearAuthCookie } from '../../../lib/auth';
export const POST: APIRoute = async ({ cookies, redirect }) => { export const POST: APIRoute = async ({ cookies }) => {
const sessionId = cookies.get('session_id')?.value; clearAuthCookie(cookies);
if (sessionId) { return new Response(null, { status: 200 });
await invalidateSession(sessionId);
cookies.delete('session_id', { path: '/' });
}
return redirect('/login');
}; };

View File

@@ -3,7 +3,7 @@ import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { db } from "../../../../../db"; import { db } from "../../../../../db";
import { users, passkeys, passkeyChallenges } from "../../../../../db/schema"; import { users, passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import { createSession } from "../../../../../lib/auth"; import { setAuthCookie, getOrigin } from "../../../../../lib/auth";
export const POST: APIRoute = async ({ request, cookies }) => { export const POST: APIRoute = async ({ request, cookies }) => {
const body = await request.json(); const body = await request.json();
@@ -50,11 +50,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
let verification; let verification;
try { try {
const { origin, hostname } = getOrigin();
verification = await verifyAuthenticationResponse({ verification = await verifyAuthenticationResponse({
response: body, response: body,
expectedChallenge: challenge as string, expectedChallenge: challenge as string,
expectedOrigin: new URL(request.url).origin, expectedOrigin: origin,
expectedRPID: new URL(request.url).hostname, expectedRPID: hostname,
credential: { credential: {
id: passkey.id, id: passkey.id,
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")), publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")),
@@ -82,15 +83,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
}) })
.where(eq(passkeys.id, passkey.id)); .where(eq(passkeys.id, passkey.id));
const { sessionId, expiresAt } = await createSession(user.id); setAuthCookie(cookies, user.id);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
await db await db
.delete(passkeyChallenges) .delete(passkeyChallenges)

View File

@@ -3,14 +3,17 @@ 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"; import { lte } from "drizzle-orm";
import { getOrigin } from "../../../../../lib/auth";
export const GET: APIRoute = async ({ request }) => { export const GET: APIRoute = async ({ request }) => {
await db await db
.delete(passkeyChallenges) .delete(passkeyChallenges)
.where(lte(passkeyChallenges.expiresAt, new Date())); .where(lte(passkeyChallenges.expiresAt, new Date()));
const { hostname } = getOrigin();
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID: new URL(request.url).hostname, rpID: hostname,
userVerification: "preferred", userVerification: "preferred",
}); });

View File

@@ -3,6 +3,7 @@ import { verifyRegistrationResponse } 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, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import { getOrigin } from "../../../../../lib/auth";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user; const user = locals.user;
@@ -41,11 +42,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
let verification; let verification;
try { try {
const { origin, hostname } = getOrigin();
verification = await verifyRegistrationResponse({ verification = await verifyRegistrationResponse({
response: body, response: body,
expectedChallenge: challenge, expectedChallenge: challenge,
expectedOrigin: new URL(request.url).origin, expectedOrigin: origin,
expectedRPID: new URL(request.url).hostname, expectedRPID: hostname,
}); });
} catch (error) { } catch (error) {
console.error("Passkey registration verification failed:", error); console.error("Passkey registration verification failed:", error);

View File

@@ -3,6 +3,7 @@ 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, lte } from "drizzle-orm"; import { eq, lte } from "drizzle-orm";
import { getOrigin } from "../../../../../lib/auth";
export const GET: APIRoute = async ({ request, locals }) => { export const GET: APIRoute = async ({ request, locals }) => {
const user = locals.user; const user = locals.user;
@@ -21,9 +22,11 @@ export const GET: APIRoute = async ({ request, locals }) => {
where: eq(passkeys.userId, user.id), where: eq(passkeys.userId, user.id),
}); });
const { hostname } = getOrigin();
const options = await generateRegistrationOptions({ const options = await generateRegistrationOptions({
rpName: "Chronus", rpName: "Chronus",
rpID: new URL(request.url).hostname, rpID: hostname,
userName: user.email, userName: user.email,
attestationType: "none", attestationType: "none",
excludeCredentials: userPasskeys.map((passkey) => ({ excludeCredentials: userPasskeys.map((passkey) => ({

View File

@@ -6,7 +6,7 @@ import {
members, members,
siteSettings, siteSettings,
} from "../../../db/schema"; } from "../../../db/schema";
import { hashPassword, createSession } from "../../../lib/auth"; import { hashPassword, setAuthCookie } from "../../../lib/auth";
import { isValidEmail, MAX_LENGTHS } from "../../../lib/validation"; 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";
@@ -86,15 +86,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
role: "owner", role: "owner",
}); });
const { sessionId, expiresAt } = await createSession(userId); setAuthCookie(cookies, userId);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
return redirect("/dashboard"); return redirect("/dashboard");
}; };

View File

@@ -1,9 +1,10 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { db } from "../../../db"; import { db } from "../../../db";
import { users, sessions } from "../../../db/schema"; import { users } 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"; import { MAX_LENGTHS } from "../../../lib/validation";
import { setAuthCookie } from "../../../lib/auth";
export const POST: APIRoute = async ({ request, locals, redirect, cookies }) => { export const POST: APIRoute = async ({ request, locals, redirect, cookies }) => {
const user = locals.user; const user = locals.user;
@@ -98,31 +99,7 @@ export const POST: APIRoute = async ({ request, locals, redirect, cookies }) =>
.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 setAuthCookie(cookies, user.id);
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

@@ -31,7 +31,7 @@ const allClients = await db.select()
<div class="card-body p-4 gap-1"> <div class="card-body p-4 gap-1">
<h2 class="font-semibold">{client.name}</h2> <h2 class="font-semibold">{client.name}</h2>
{client.email && <p class="text-sm text-base-content/60">{client.email}</p>} {client.email && <p class="text-sm text-base-content/60">{client.email}</p>}
<p class="text-xs text-base-content/40">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p> <p class="text-xs text-base-content/60">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
<div class="card-actions justify-end mt-3"> <div class="card-actions justify-end mt-3">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-xs btn-ghost">View</a> <a href={`/dashboard/clients/${client.id}`} class="btn btn-xs btn-ghost">View</a>
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-xs btn-primary">Edit</a> <a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-xs btn-primary">Edit</a>
@@ -43,7 +43,7 @@ const allClients = await db.select()
{allClients.length === 0 && ( {allClients.length === 0 && (
<div class="flex flex-col items-center justify-center py-12 text-center"> <div class="flex flex-col items-center justify-center py-12 text-center">
<p class="text-base-content/50 text-sm mb-4">No clients yet</p> <p class="text-base-content/60 text-sm mb-4">No clients yet</p>
<a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Your First Client</a> <a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Your First Client</a>
</div> </div>
)} )}

View File

@@ -1,6 +1,7 @@
--- ---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import Icon from '../../../../components/Icon.astro'; import Icon from '../../../../components/Icon.astro';
import ModalButton from '../../../../components/ModalButton.vue';
import { db } from '../../../../db'; import { db } from '../../../../db';
import { clients } from '../../../../db/schema'; import { clients } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
@@ -74,7 +75,7 @@ if (!client) return Astro.redirect('/dashboard/clients');
/> />
</fieldset> </fieldset>
<div class="divider text-xs text-base-content/40">Address Details</div> <div class="divider text-xs text-base-content/60">Address Details</div>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Street Address (optional)</legend> <legend class="fieldset-legend text-xs">Street Address (optional)</legend>
@@ -141,13 +142,13 @@ if (!client) return Astro.redirect('/dashboard/clients');
</div> </div>
<div class="flex justify-between items-center mt-4"> <div class="flex justify-between items-center mt-4">
<button <ModalButton
type="button" client:load
modalId="delete_modal"
class="btn btn-error btn-outline btn-sm" class="btn btn-error btn-outline btn-sm"
onclick={`document.getElementById('delete_modal').showModal()`}
> >
Delete Client Delete Client
</button> </ModalButton>
<div class="flex gap-2"> <div class="flex gap-2">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">Cancel</a> <a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">Cancel</a>

View File

@@ -1,6 +1,8 @@
--- ---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import Icon from '../../../../components/Icon.astro'; import Icon from '../../../../components/Icon.astro';
import ConfirmForm from '../../../../components/ConfirmForm.vue';
import ColorDot from '../../../../components/ColorDot.vue';
import { db } from '../../../../db'; import { db } from '../../../../db';
import { clients, timeEntries, 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';
@@ -110,12 +112,12 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<Icon name="pencil" class="w-3 h-3" /> <Icon name="pencil" class="w-3 h-3" />
Edit Edit
</a> </a>
<form method="POST" action={`/api/clients/${client.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this client? This will also delete all associated time entries.');"> <ConfirmForm client:load message="Are you sure you want to delete this client? This will also delete all associated time entries." action={`/api/clients/${client.id}/delete`}>
<button type="submit" class="btn btn-error btn-outline btn-xs"> <button type="submit" class="btn btn-error btn-outline btn-xs">
<Icon name="trash" class="w-3 h-3" /> <Icon name="trash" class="w-3 h-3" />
Delete Delete
</button> </button>
</form> </ConfirmForm>
</div> </div>
</div> </div>
@@ -147,7 +149,7 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<div class="text-xs text-base-content/40">Created</div> <div class="text-xs text-base-content/60">Created</div>
<div class="text-sm">{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div> <div class="text-sm">{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
</div> </div>
</div> </div>
@@ -158,7 +160,7 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<!-- Recent Activity --> <!-- Recent Activity -->
<div class="card card-border bg-base-100"> <div class="card card-border bg-base-100">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="px-4 py-3 border-b border-base-200"> <div class="px-4 py-3 border-b border-base-content/20">
<h2 class="text-sm font-semibold">Recent Activity</h2> <h2 class="text-sm font-semibold">Recent Activity</h2>
</div> </div>
@@ -182,14 +184,14 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
{tag ? ( {tag ? (
<div class="badge badge-xs badge-outline flex items-center gap-1"> <div class="badge badge-xs badge-outline flex items-center gap-1">
{tag.color && ( {tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span> <ColorDot client:load color={tag.color} class="w-2 h-2 rounded-full" />
)} )}
<span>{tag.name}</span> <span>{tag.name}</span>
</div> </div>
) : '-'} ) : '-'}
</td> </td>
<td class="text-base-content/60">{entryUser?.name || 'Unknown'}</td> <td class="text-base-content/60">{entryUser?.name || 'Unknown'}</td>
<td class="text-base-content/40">{entry.startTime.toLocaleDateString()}</td> <td class="text-base-content/60">{entry.startTime.toLocaleDateString()}</td>
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td> <td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
</tr> </tr>
))} ))}
@@ -197,13 +199,13 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
</table> </table>
</div> </div>
) : ( ) : (
<div class="text-center py-8 text-base-content/40 text-sm"> <div class="text-center py-8 text-base-content/60 text-sm">
No time entries recorded for this client yet. No time entries recorded for this client yet.
</div> </div>
)} )}
{recentEntries.length > 0 && ( {recentEntries.length > 0 && (
<div class="flex justify-center py-3 border-t border-base-200"> <div class="flex justify-center py-3 border-t border-base-content/20">
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-xs"> <a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-xs">
View All Entries View All Entries
</a> </a>

View File

@@ -45,7 +45,7 @@ if (!user) return Astro.redirect('/login');
/> />
</fieldset> </fieldset>
<div class="divider text-xs text-base-content/40">Address Details</div> <div class="divider text-xs text-base-content/60">Address Details</div>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Street Address (optional)</legend> <legend class="fieldset-legend text-xs">Street Address (optional)</legend>

View File

@@ -2,6 +2,7 @@
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import Icon from '../../components/Icon.astro'; import Icon from '../../components/Icon.astro';
import StatCard from '../../components/StatCard.astro'; import StatCard from '../../components/StatCard.astro';
import ColorDot from '../../components/ColorDot.vue';
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';
@@ -200,9 +201,9 @@ const hasMembership = userOrgs.length > 0;
{stats.recentEntries.length > 0 ? ( {stats.recentEntries.length > 0 ? (
<ul class="space-y-2 mt-3"> <ul class="space-y-2 mt-3">
{stats.recentEntries.map(({ entry, client, tag }) => ( {stats.recentEntries.map(({ entry, client, tag }) => (
<li class="p-2.5 rounded-lg bg-base-200/50 border-l-3 hover:bg-base-200 transition-colors" style={`border-color: ${tag?.color || 'oklch(var(--p))'}`}> <ColorDot client:load as="li" color={tag?.color || 'oklch(var(--p))'} borderColor class="p-2.5 rounded-lg bg-base-200 border-l-3 hover:bg-base-300 transition-colors">
<div class="font-medium text-sm">{client.name}</div> <div class="font-medium text-sm">{client.name}</div>
<div class="text-xs text-base-content/50 mt-0.5 flex flex-wrap gap-2 items-center"> <div class="text-xs text-base-content/60 mt-0.5 flex flex-wrap gap-2 items-center">
<span class="flex gap-1 flex-wrap"> <span class="flex gap-1 flex-wrap">
{tag ? ( {tag ? (
<span class="badge badge-xs badge-outline">{tag.name}</span> <span class="badge badge-xs badge-outline">{tag.name}</span>
@@ -210,13 +211,13 @@ const hasMembership = userOrgs.length > 0;
</span> </span>
<span>· {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span> <span>· {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
</div> </div>
</li> </ColorDot>
))} ))}
</ul> </ul>
) : ( ) : (
<div class="flex flex-col items-center justify-center py-6 text-center mt-3"> <div class="flex flex-col items-center justify-center py-6 text-center mt-3">
<Icon name="clock" class="w-10 h-10 text-base-content/15 mb-2" /> <Icon name="clock" class="w-10 h-10 text-base-content/30 mb-2" />
<p class="text-base-content/40 text-sm">No recent time entries</p> <p class="text-base-content/60 text-sm">No recent time entries</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,6 +1,8 @@
--- ---
import DashboardLayout from '../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import Icon from '../../../components/Icon.astro'; import Icon from '../../../components/Icon.astro';
import ConfirmForm from '../../../components/ConfirmForm.vue';
import ModalButton from '../../../components/ModalButton.vue';
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';
@@ -111,10 +113,10 @@ const isDraft = invoice.status === 'draft';
</form> </form>
)} )}
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<div role="button" tabindex="0" class="btn btn-square btn-ghost btn-sm border border-base-200"> <div role="button" tabindex="0" class="btn btn-square btn-ghost btn-sm border border-base-content/20">
<Icon name="ellipsis-horizontal" class="w-4 h-4" /> <Icon name="ellipsis-horizontal" class="w-4 h-4" />
</div> </div>
<ul tabindex="0" class="dropdown-content z-1 menu p-2 bg-base-100 rounded-box w-52 border border-base-200"> <ul tabindex="0" class="dropdown-content z-1 menu p-2 bg-base-100 rounded-box w-52 border border-base-content/20">
<li> <li>
<a href={`/dashboard/invoices/${invoice.id}/edit`}> <a href={`/dashboard/invoices/${invoice.id}/edit`}>
<Icon name="pencil-square" class="w-4 h-4" /> <Icon name="pencil-square" class="w-4 h-4" />
@@ -139,13 +141,13 @@ const isDraft = invoice.status === 'draft';
</li> </li>
)} )}
<li> <li>
<form method="POST" action="/api/invoices/delete" onsubmit="return confirm('Are you sure?');"> <ConfirmForm client:load message="Are you sure?" action="/api/invoices/delete">
<input type="hidden" name="id" value={invoice.id} /> <input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="text-error"> <button type="submit" class="text-error">
<Icon name="trash" class="w-4 h-4" /> <Icon name="trash" class="w-4 h-4" />
Delete Delete
</button> </button>
</form> </ConfirmForm>
</li> </li>
</ul> </ul>
</div> </div>
@@ -172,7 +174,7 @@ const isDraft = invoice.status === 'draft';
)} )}
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="text-4xl font-light text-base-content/30 uppercase tracking-widest mb-4"> <div class="text-4xl font-light text-base-content/50 uppercase tracking-widest mb-4">
{invoice.type} {invoice.type}
</div> </div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm"> <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
@@ -188,7 +190,7 @@ const isDraft = invoice.status === 'draft';
<!-- Bill To --> <!-- Bill To -->
<div class="mb-12"> <div class="mb-12">
<div class="text-xs font-bold uppercase tracking-wider text-base-content/40 mb-2">Bill To</div> <div class="text-xs font-bold uppercase tracking-wider text-base-content/60 mb-2">Bill To</div>
{client ? ( {client ? (
<div> <div>
<div class="font-bold text-lg">{client.name}</div> <div class="font-bold text-lg">{client.name}</div>
@@ -207,7 +209,7 @@ const isDraft = invoice.status === 'draft';
)} )}
</div> </div>
) : ( ) : (
<div class="italic text-base-content/40">Client deleted</div> <div class="italic text-base-content/60">Client deleted</div>
)} )}
</div> </div>
@@ -216,7 +218,7 @@ const isDraft = invoice.status === 'draft';
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full min-w-150"> <table class="w-full min-w-150">
<thead> <thead>
<tr class="border-b-2 border-base-200 text-left text-xs font-bold uppercase tracking-wider text-base-content/40"> <tr class="border-b-2 border-base-content/20 text-left text-xs font-bold uppercase tracking-wider text-base-content/60">
<th class="py-3">Description</th> <th class="py-3">Description</th>
<th class="py-3 text-right w-24">Qty</th> <th class="py-3 text-right w-24">Qty</th>
<th class="py-3 text-right w-32">Price</th> <th class="py-3 text-right w-32">Price</th>
@@ -224,7 +226,7 @@ const isDraft = invoice.status === 'draft';
{isDraft && <th class="py-3 w-10"></th>} {isDraft && <th class="py-3 w-10"></th>}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-base-200"> <tbody class="divide-y divide-base-content/20">
{items.map(item => ( {items.map(item => (
<tr> <tr>
<td class="py-4">{item.description}</td> <td class="py-4">{item.description}</td>
@@ -235,7 +237,7 @@ const isDraft = invoice.status === 'draft';
<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`}>
<input type="hidden" name="itemId" value={item.id} /> <input type="hidden" name="itemId" value={item.id} />
<button type="submit" class="btn btn-ghost btn-xs btn-square text-error opacity-50 hover:opacity-100"> <button type="submit" class="btn btn-ghost btn-xs btn-square text-error opacity-70 hover:opacity-100">
<Icon name="trash" class="w-4 h-4" /> <Icon name="trash" class="w-4 h-4" />
</button> </button>
</form> </form>
@@ -245,7 +247,7 @@ const isDraft = invoice.status === 'draft';
))} ))}
{items.length === 0 && ( {items.length === 0 && (
<tr> <tr>
<td colspan={isDraft ? 5 : 4} class="py-8 text-center text-base-content/40 italic"> <td colspan={isDraft ? 5 : 4} class="py-8 text-center text-base-content/60 italic">
No items added yet. No items added yet.
</td> </td>
</tr> </tr>
@@ -258,13 +260,13 @@ const isDraft = invoice.status === 'draft';
<!-- Add Item Form (Only if Draft) --> <!-- Add Item Form (Only if Draft) -->
{isDraft && ( {isDraft && (
<div class="flex justify-end mb-4"> <div class="flex justify-end mb-4">
<button onclick="document.getElementById('import_time_modal').showModal()" class="btn btn-sm btn-outline gap-2"> <ModalButton client:load modalId="import_time_modal" class="btn btn-sm btn-outline gap-2">
<Icon name="clock" class="w-4 h-4" /> <Icon name="clock" class="w-4 h-4" />
Import Time Import Time
</button> </ModalButton>
</div> </div>
<form method="POST" action={`/api/invoices/${invoice.id}/items/add`} class="bg-base-200/50 p-4 rounded-lg mb-8 border border-base-200"> <form method="POST" action={`/api/invoices/${invoice.id}/items/add`} class="bg-base-200 p-4 rounded-lg mb-8 border border-base-content/20">
<h4 class="text-xs font-semibold mb-3">Add Item</h4> <h4 class="text-xs font-semibold mb-3">Add Item</h4>
<div class="grid grid-cols-1 sm:grid-cols-12 gap-3 items-end"> <div class="grid grid-cols-1 sm:grid-cols-12 gap-3 items-end">
<div class="sm:col-span-6"> <div class="sm:col-span-6">
@@ -309,9 +311,9 @@ const isDraft = invoice.status === 'draft';
<span class="text-base-content/60 flex items-center gap-2"> <span class="text-base-content/60 flex items-center gap-2">
Tax ({invoice.taxRate ?? 0}%) Tax ({invoice.taxRate ?? 0}%)
{isDraft && ( {isDraft && (
<button type="button" onclick="document.getElementById('tax_modal').showModal()" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate"> <ModalButton client:load modalId="tax_modal" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
<Icon name="pencil" class="w-3 h-3" /> <Icon name="pencil" class="w-3 h-3" />
</button> </ModalButton>
)} )}
</span> </span>
<span class="font-medium">{formatCurrency(invoice.taxAmount, invoice.currency)}</span> <span class="font-medium">{formatCurrency(invoice.taxAmount, invoice.currency)}</span>
@@ -327,8 +329,8 @@ const isDraft = invoice.status === 'draft';
<!-- Notes --> <!-- Notes -->
{invoice.notes && ( {invoice.notes && (
<div class="mt-12 pt-8 border-t border-base-200"> <div class="mt-12 pt-8 border-t border-base-content/20">
<div class="text-xs font-bold uppercase tracking-wider text-base-content/40 mb-2">Notes</div> <div class="text-xs font-bold uppercase tracking-wider text-base-content/60 mb-2">Notes</div>
<div class="text-sm whitespace-pre-wrap opacity-80">{invoice.notes}</div> <div class="text-sm whitespace-pre-wrap opacity-80">{invoice.notes}</div>
</div> </div>
)} )}
@@ -364,7 +366,7 @@ const isDraft = invoice.status === 'draft';
/> />
</fieldset> </fieldset>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-sm" onclick="document.getElementById('tax_modal').close()">Cancel</button> <ModalButton client:load modalId="tax_modal" action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">Update</button> <button type="submit" class="btn btn-primary btn-sm">Update</button>
</div> </div>
</form> </form>
@@ -397,7 +399,7 @@ const isDraft = invoice.status === 'draft';
</label> </label>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-sm" onclick="document.getElementById('import_time_modal').close()">Cancel</button> <ModalButton client:load modalId="import_time_modal" action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">Import</button> <button type="submit" class="btn btn-primary btn-sm">Import</button>
</div> </div>
</form> </form>

View File

@@ -2,6 +2,8 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import Icon from '../../../components/Icon.astro'; import Icon from '../../../components/Icon.astro';
import StatCard from '../../../components/StatCard.astro'; import StatCard from '../../../components/StatCard.astro';
import AutoSubmit from '../../../components/AutoSubmit.vue';
import ConfirmForm from '../../../components/ConfirmForm.vue';
import { db } from '../../../db'; import { db } from '../../../db';
import { invoices, clients } 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';
@@ -145,46 +147,54 @@ const getStatusColor = (status: string) => {
<form method="GET" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3"> <form method="GET" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Year</legend> <legend class="fieldset-legend text-xs">Year</legend>
<select name="year" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option> <select name="year" class="select w-full">
{availableYears.map(year => ( <option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option>
<option value={year} selected={year === selectedYear}>{year}</option> {availableYears.map(year => (
))} <option value={year} selected={year === selectedYear}>{year}</option>
</select> ))}
</select>
</AutoSubmit>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Type</legend> <legend class="fieldset-legend text-xs">Type</legend>
<select name="type" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="all" selected={selectedType === 'all'}>All Types</option> <select name="type" class="select w-full">
<option value="invoice" selected={selectedType === 'invoice'}>Invoices</option> <option value="all" selected={selectedType === 'all'}>All Types</option>
<option value="quote" selected={selectedType === 'quote'}>Quotes</option> <option value="invoice" selected={selectedType === 'invoice'}>Invoices</option>
</select> <option value="quote" selected={selectedType === 'quote'}>Quotes</option>
</select>
</AutoSubmit>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Status</legend> <legend class="fieldset-legend text-xs">Status</legend>
<select name="status" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="all" selected={selectedStatus === 'all'}>All Statuses</option> <select name="status" class="select w-full">
<option value="draft" selected={selectedStatus === 'draft'}>Draft</option> <option value="all" selected={selectedStatus === 'all'}>All Statuses</option>
<option value="sent" selected={selectedStatus === 'sent'}>Sent</option> <option value="draft" selected={selectedStatus === 'draft'}>Draft</option>
<option value="paid" selected={selectedStatus === 'paid'}>Paid</option> <option value="sent" selected={selectedStatus === 'sent'}>Sent</option>
<option value="accepted" selected={selectedStatus === 'accepted'}>Accepted</option> <option value="paid" selected={selectedStatus === 'paid'}>Paid</option>
<option value="declined" selected={selectedStatus === 'declined'}>Declined</option> <option value="accepted" selected={selectedStatus === 'accepted'}>Accepted</option>
<option value="void" selected={selectedStatus === 'void'}>Void</option> <option value="declined" selected={selectedStatus === 'declined'}>Declined</option>
</select> <option value="void" selected={selectedStatus === 'void'}>Void</option>
</select>
</AutoSubmit>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Sort By</legend> <legend class="fieldset-legend text-xs">Sort By</legend>
<select name="sort" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="date-desc" selected={sortBy === 'date-desc'}>Date (Newest First)</option> <select name="sort" class="select w-full">
<option value="date-asc" selected={sortBy === 'date-asc'}>Date (Oldest First)</option> <option value="date-desc" selected={sortBy === 'date-desc'}>Date (Newest First)</option>
<option value="amount-desc" selected={sortBy === 'amount-desc'}>Amount (High to Low)</option> <option value="date-asc" selected={sortBy === 'date-asc'}>Date (Oldest First)</option>
<option value="amount-asc" selected={sortBy === 'amount-asc'}>Amount (Low to High)</option> <option value="amount-desc" selected={sortBy === 'amount-desc'}>Amount (High to Low)</option>
<option value="number-desc" selected={sortBy === 'number-desc'}>Number (Z-A)</option> <option value="amount-asc" selected={sortBy === 'amount-asc'}>Amount (Low to High)</option>
<option value="number-asc" selected={sortBy === 'number-asc'}>Number (A-Z)</option> <option value="number-desc" selected={sortBy === 'number-desc'}>Number (Z-A)</option>
</select> <option value="number-asc" selected={sortBy === 'number-asc'}>Number (A-Z)</option>
</select>
</AutoSubmit>
</fieldset> </fieldset>
</form> </form>
@@ -201,8 +211,8 @@ const getStatusColor = (status: string) => {
<div class="card card-border bg-base-100"> <div class="card card-border bg-base-100">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="px-4 py-3 border-b border-base-200"> <div class="px-4 py-3 border-b border-base-content/20">
<p class="text-xs text-base-content/50"> <p class="text-xs text-base-content/60">
Showing <span class="font-semibold text-base-content">{allInvoices.length}</span> Showing <span class="font-semibold text-base-content">{allInvoices.length}</span>
{allInvoices.length === 1 ? 'result' : 'results'} {allInvoices.length === 1 ? 'result' : 'results'}
{selectedYear === 'current' ? ` for ${currentYear} (year to date)` : ` for ${selectedYear}`} {selectedYear === 'current' ? ` for ${currentYear} (year to date)` : ` for ${selectedYear}`}
@@ -225,7 +235,7 @@ const getStatusColor = (status: string) => {
<tbody> <tbody>
{allInvoices.length === 0 ? ( {allInvoices.length === 0 ? (
<tr> <tr>
<td colspan="8" class="text-center py-8 text-base-content/50 text-sm"> <td colspan="8" class="text-center py-8 text-base-content/60 text-sm">
No invoices or quotes found. Create one to get started. No invoices or quotes found. Create one to get started.
</td> </td>
</tr> </tr>
@@ -241,7 +251,7 @@ const getStatusColor = (status: string) => {
{client ? ( {client ? (
<div class="font-medium">{client.name}</div> <div class="font-medium">{client.name}</div>
) : ( ) : (
<span class="text-base-content/40 italic">Deleted Client</span> <span class="text-base-content/60 italic">Deleted Client</span>
)} )}
</td> </td>
<td>{invoice.issueDate.toLocaleDateString()}</td> <td>{invoice.issueDate.toLocaleDateString()}</td>
@@ -262,7 +272,7 @@ const getStatusColor = (status: string) => {
<div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square"> <div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square">
<Icon name="ellipsis-vertical" class="w-4 h-4" /> <Icon name="ellipsis-vertical" class="w-4 h-4" />
</div> </div>
<ul tabindex="0" class="dropdown-content menu p-2 bg-base-100 rounded-box w-52 border border-base-200 z-100"> <ul tabindex="0" class="dropdown-content menu p-2 bg-base-100 rounded-box w-52 border border-base-content/20 z-100">
<li> <li>
<a href={`/dashboard/invoices/${invoice.id}`}> <a href={`/dashboard/invoices/${invoice.id}`}>
<Icon name="eye" class="w-4 h-4" /> <Icon name="eye" class="w-4 h-4" />
@@ -294,13 +304,13 @@ const getStatusColor = (status: string) => {
)} )}
<div class="divider my-1"></div> <div class="divider my-1"></div>
<li> <li>
<form method="POST" action={`/api/invoices/delete`} onsubmit="return confirm('Are you sure? This action cannot be undone.');" class="w-full"> <ConfirmForm client:load message="Are you sure? This action cannot be undone." action="/api/invoices/delete" class="w-full">
<input type="hidden" name="id" value={invoice.id} /> <input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="w-full justify-start text-error hover:bg-error/10"> <button type="submit" class="w-full justify-start text-error hover:bg-base-300">
<Icon name="trash" class="w-4 h-4" /> <Icon name="trash" class="w-4 h-4" />
Delete Delete
</button> </button>
</form> </ConfirmForm>
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -104,11 +104,11 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Document Type</legend> <legend class="fieldset-legend text-xs">Document Type</legend>
<div class="flex gap-3"> <div class="flex gap-3">
<label class="label cursor-pointer justify-start gap-2 border border-base-200 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 transition-all font-medium text-sm" for="document-type-invoice"> <label class="label cursor-pointer justify-start gap-2 border border-base-content/20 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-base-200 transition-all font-medium text-sm" for="document-type-invoice">
<input type="radio" id="document-type-invoice" name="type" value="invoice" class="radio radio-primary radio-sm" checked /> <input type="radio" id="document-type-invoice" name="type" value="invoice" class="radio radio-primary radio-sm" checked />
Invoice Invoice
</label> </label>
<label class="label cursor-pointer justify-start gap-2 border border-base-200 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 transition-all font-medium text-sm" for="document-type-quote"> <label class="label cursor-pointer justify-start gap-2 border border-base-content/20 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-base-200 transition-all font-medium text-sm" for="document-type-quote">
<input type="radio" id="document-type-quote" name="type" value="quote" class="radio radio-primary radio-sm" /> <input type="radio" id="document-type-quote" name="type" value="quote" class="radio radio-primary radio-sm" />
Quote / Estimate Quote / Estimate
</label> </label>

View File

@@ -5,6 +5,8 @@ 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 AutoSubmit from '../../components/AutoSubmit.vue';
import ColorDot from '../../components/ColorDot.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';
@@ -260,78 +262,88 @@ function getTimeRangeLabel(range: string) {
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3"> <form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Time Range</legend> <legend class="fieldset-legend text-xs">Time Range</legend>
<select id="reports-range" name="range" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="today" selected={timeRange === 'today'}>Today</option> <select id="reports-range" name="range" class="select w-full">
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option> <option value="today" selected={timeRange === 'today'}>Today</option>
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option> <option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option> <option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option> <option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option> <option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option> <option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
</select> <option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
</select>
</AutoSubmit>
</fieldset> </fieldset>
{timeRange === 'custom' && ( {timeRange === 'custom' && (
<> <>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">From Date</legend> <legend class="fieldset-legend text-xs">From Date</legend>
<input <AutoSubmit client:load>
type="date" <input
id="reports-from" type="date"
name="from" id="reports-from"
class="input w-full" name="from"
value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))} class="input w-full"
onchange="this.form.submit()" value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))}
/> />
</AutoSubmit>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">To Date</legend> <legend class="fieldset-legend text-xs">To Date</legend>
<input <AutoSubmit client:load>
type="date" <input
id="reports-to" type="date"
name="to" id="reports-to"
class="input w-full" name="to"
value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))} class="input w-full"
onchange="this.form.submit()" value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))}
/> />
</AutoSubmit>
</fieldset> </fieldset>
</> </>
)} )}
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Team Member</legend> <legend class="fieldset-legend text-xs">Team Member</legend>
<select id="reports-member" name="member" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="">All Members</option> <select id="reports-member" name="member" class="select w-full">
{teamMembers.map(member => ( <option value="">All Members</option>
<option value={member.id} selected={selectedMemberId === member.id}> {teamMembers.map(member => (
{member.name} <option value={member.id} selected={selectedMemberId === member.id}>
</option> {member.name}
))} </option>
</select> ))}
</select>
</AutoSubmit>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Tag</legend> <legend class="fieldset-legend text-xs">Tag</legend>
<select id="reports-tag" name="tag" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="">All Tags</option> <select id="reports-tag" name="tag" class="select w-full">
{allTags.map(tag => ( <option value="">All Tags</option>
<option value={tag.id} selected={selectedTagId === tag.id}> {allTags.map(tag => (
{tag.name} <option value={tag.id} selected={selectedTagId === tag.id}>
</option> {tag.name}
))} </option>
</select> ))}
</select>
</AutoSubmit>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Client</legend> <legend class="fieldset-legend text-xs">Client</legend>
<select id="reports-client" name="client" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="">All Clients</option> <select id="reports-client" name="client" class="select w-full">
{allClients.map(client => ( <option value="">All Clients</option>
<option value={client.id} selected={selectedClientId === client.id}> {allClients.map(client => (
{client.name} <option value={client.id} selected={selectedClientId === client.id}>
</option> {client.name}
))} </option>
</select> ))}
</select>
</AutoSubmit>
</fieldset> </fieldset>
</form> </form>
</div> </div>
@@ -382,11 +394,11 @@ function getTimeRangeLabel(range: string) {
<div class="stat-title text-xs">Total Invoices</div> <div class="stat-title text-xs">Total Invoices</div>
<div class="stat-value text-2xl">{invoiceStats.total}</div> <div class="stat-value text-2xl">{invoiceStats.total}</div>
</div> </div>
<div class="stat bg-success/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Paid</div> <div class="stat-title text-xs">Paid</div>
<div class="stat-value text-2xl text-success">{invoiceStats.paid}</div> <div class="stat-value text-2xl text-success">{invoiceStats.paid}</div>
</div> </div>
<div class="stat bg-info/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Sent</div> <div class="stat-title text-xs">Sent</div>
<div class="stat-value text-2xl text-info">{invoiceStats.sent}</div> <div class="stat-value text-2xl text-info">{invoiceStats.sent}</div>
</div> </div>
@@ -418,15 +430,15 @@ function getTimeRangeLabel(range: string) {
<div class="stat-title text-xs">Total Quotes</div> <div class="stat-title text-xs">Total Quotes</div>
<div class="stat-value text-2xl">{quoteStats.total}</div> <div class="stat-value text-2xl">{quoteStats.total}</div>
</div> </div>
<div class="stat bg-success/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Accepted</div> <div class="stat-title text-xs">Accepted</div>
<div class="stat-value text-2xl text-success">{quoteStats.accepted}</div> <div class="stat-value text-2xl text-success">{quoteStats.accepted}</div>
</div> </div>
<div class="stat bg-info/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Pending</div> <div class="stat-title text-xs">Pending</div>
<div class="stat-value text-2xl text-info">{quoteStats.sent}</div> <div class="stat-value text-2xl text-info">{quoteStats.sent}</div>
</div> </div>
<div class="stat bg-error/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Declined</div> <div class="stat-title text-xs">Declined</div>
<div class="stat-value text-2xl text-error">{quoteStats.declined}</div> <div class="stat-value text-2xl text-error">{quoteStats.declined}</div>
</div> </div>
@@ -579,7 +591,7 @@ function getTimeRangeLabel(range: string) {
<td> <td>
<div> <div>
<div class="font-medium">{stat.member.name}</div> <div class="font-medium">{stat.member.name}</div>
<div class="text-xs text-base-content/40">{stat.member.email}</div> <div class="text-xs text-base-content/60">{stat.member.email}</div>
</div> </div>
</td> </td>
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td> <td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
@@ -620,7 +632,7 @@ function getTimeRangeLabel(range: string) {
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{stat.tag.color && ( {stat.tag.color && (
<span class="w-3 h-3 rounded-full" style={`background-color: ${stat.tag.color}`}></span> <ColorDot client:load color={stat.tag.color} class="w-3 h-3 rounded-full" />
)} )}
<span>{stat.tag.name}</span> <span>{stat.tag.name}</span>
</div> </div>
@@ -726,7 +738,7 @@ function getTimeRangeLabel(range: string) {
<tr> <tr>
<td class="whitespace-nowrap"> <td class="whitespace-nowrap">
{e.entry.startTime.toLocaleDateString()}<br/> {e.entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs text-base-content/40"> <span class="text-xs text-base-content/60">
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} {e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span> </span>
</td> </td>
@@ -736,12 +748,12 @@ function getTimeRangeLabel(range: string) {
{e.tag ? ( {e.tag ? (
<div class="badge badge-xs badge-outline flex items-center gap-1"> <div class="badge badge-xs badge-outline flex items-center gap-1">
{e.tag.color && ( {e.tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${e.tag.color}`}></span> <ColorDot client:load color={e.tag.color} class="w-2 h-2 rounded-full" />
)} )}
<span>{e.tag.name}</span> <span>{e.tag.name}</span>
</div> </div>
) : ( ) : (
<span class="text-base-content/30">-</span> <span class="text-base-content/60">-</span>
)} )}
</td> </td>
<td class="text-base-content/60">{e.entry.description || '-'}</td> <td class="text-base-content/60">{e.entry.description || '-'}</td>
@@ -758,9 +770,9 @@ function getTimeRangeLabel(range: string) {
</div> </div>
) : ( ) : (
<div class="flex flex-col items-center justify-center py-10 text-center"> <div class="flex flex-col items-center justify-center py-10 text-center">
<Icon name="inbox" class="w-12 h-12 text-base-content/15 mb-3" /> <Icon name="inbox" class="w-12 h-12 text-base-content/30 mb-3" />
<h3 class="text-base font-semibold mb-1">No time entries found</h3> <h3 class="text-base font-semibold mb-1">No time entries found</h3>
<p class="text-base-content/50 text-sm mb-4">Try adjusting your filters or select a different time range.</p> <p class="text-base-content/60 text-sm mb-4">Try adjusting your filters or select a different time range.</p>
<a href="/dashboard/tracker" class="btn btn-primary btn-sm"> <a href="/dashboard/tracker" class="btn btn-primary btn-sm">
<Icon name="play" class="w-4 h-4" /> <Icon name="play" class="w-4 h-4" />
Start Tracking Time Start Tracking Time

View File

@@ -77,11 +77,11 @@ const userPasskeys = await db.select()
</h2> </h2>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-200 gap-2 sm:gap-0"> <div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-content/20 gap-2 sm:gap-0">
<span class="text-base-content/60 text-sm">Account ID</span> <span class="text-base-content/60 text-sm">Account ID</span>
<span class="font-mono text-xs break-all">{user.id}</span> <span class="font-mono text-xs break-all">{user.id}</span>
</div> </div>
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-200 gap-2 sm:gap-0"> <div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-content/20 gap-2 sm:gap-0">
<span class="text-base-content/60 text-sm">Email</span> <span class="text-base-content/60 text-sm">Email</span>
<span class="text-sm break-all">{user.email}</span> <span class="text-sm break-all">{user.email}</span>
</div> </div>

View File

@@ -82,7 +82,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
{member.role} {member.role}
</span> </span>
</td> </td>
<td class="text-base-content/40">{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td> <td class="text-base-content/60">{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td>
{isAdmin && ( {isAdmin && (
<td> <td>
{teamUser.id !== user.id && member.role !== 'owner' && ( {teamUser.id !== user.id && member.role !== 'owner' && (
@@ -90,7 +90,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
<div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square"> <div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square">
<Icon name="ellipsis-vertical" class="w-4 h-4" /> <Icon name="ellipsis-vertical" class="w-4 h-4" />
</div> </div>
<ul tabindex="0" class="dropdown-content z-1 menu p-2 bg-base-100 rounded-box w-52 border border-base-200"> <ul tabindex="0" class="dropdown-content z-1 menu p-2 bg-base-100 rounded-box w-52 border border-base-content/20">
<li> <li>
<form method="POST" action={`/api/team/change-role`}> <form method="POST" action={`/api/team/change-role`}>
<input type="hidden" name="userId" value={teamUser.id} /> <input type="hidden" name="userId" value={teamUser.id} />

View File

@@ -56,7 +56,7 @@ if (!isAdmin) return Astro.redirect('/dashboard/team');
<option value="member">Member</option> <option value="member">Member</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
<p class="text-xs text-base-content/40 mt-1">Members can track time. Admins can manage team and clients.</p> <p class="text-xs text-base-content/60 mt-1">Members can track time. Admins can manage team and clients.</p>
</fieldset> </fieldset>
<div class="flex justify-end gap-2 mt-4"> <div class="flex justify-end gap-2 mt-4">

View File

@@ -1,6 +1,9 @@
--- ---
import DashboardLayout from '../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import Icon from '../../../components/Icon.astro'; import Icon from '../../../components/Icon.astro';
import ModalButton from '../../../components/ModalButton.vue';
import ConfirmForm from '../../../components/ConfirmForm.vue';
import ColorDot from '../../../components/ColorDot.vue';
import { db } from '../../../db'; import { db } from '../../../db';
import { organizations, tags } from '../../../db/schema'; import { organizations, tags } from '../../../db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
@@ -70,7 +73,7 @@ const successType = url.searchParams.get('success');
<legend class="fieldset-legend text-xs">Team Logo</legend> <legend class="fieldset-legend text-xs">Team Logo</legend>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="avatar placeholder"> <div class="avatar placeholder">
<div class="bg-base-200 text-neutral-content rounded-xl w-20 border border-base-200 flex items-center justify-center overflow-hidden"> <div class="bg-base-200 text-neutral-content rounded-xl w-20 border border-base-content/20 flex items-center justify-center overflow-hidden">
{organization.logoUrl ? ( {organization.logoUrl ? (
<img <img
src={organization.logoUrl} src={organization.logoUrl}
@@ -80,7 +83,7 @@ const successType = url.searchParams.get('success');
) : ( ) : (
<Icon <Icon
name="photo" name="photo"
class="w-6 h-6 opacity-40 text-base-content" class="w-6 h-6 opacity-70 text-base-content"
/> />
)} )}
</div> </div>
@@ -92,7 +95,7 @@ const successType = url.searchParams.get('success');
accept="image/png, image/jpeg" accept="image/png, image/jpeg"
class="file-input file-input-bordered file-input-sm w-full max-w-xs" class="file-input file-input-bordered file-input-sm w-full max-w-xs"
/> />
<div class="text-xs text-base-content/40 mt-1"> <div class="text-xs text-base-content/60 mt-1">
Upload a company logo (PNG, JPG). Will be displayed on invoices and quotes. Upload a company logo (PNG, JPG). Will be displayed on invoices and quotes.
</div> </div>
</div> </div>
@@ -110,10 +113,10 @@ const successType = url.searchParams.get('success');
class="input w-full" class="input w-full"
required required
/> />
<p class="text-xs text-base-content/40 mt-1">This name is visible to all team members</p> <p class="text-xs text-base-content/60 mt-1">This name is visible to all team members</p>
</fieldset> </fieldset>
<div class="divider text-xs text-base-content/40 my-2">Address Information</div> <div class="divider text-xs text-base-content/60 my-2">Address Information</div>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Street Address</legend> <legend class="fieldset-legend text-xs">Street Address</legend>
@@ -179,7 +182,7 @@ const successType = url.searchParams.get('success');
</fieldset> </fieldset>
</div> </div>
<div class="divider text-xs text-base-content/40 my-2">Defaults</div> <div class="divider text-xs text-base-content/60 my-2">Defaults</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<fieldset class="fieldset"> <fieldset class="fieldset">
@@ -213,7 +216,7 @@ const successType = url.searchParams.get('success');
</div> </div>
<div class="flex flex-col sm:flex-row justify-between items-center gap-3 mt-4"> <div class="flex flex-col sm:flex-row justify-between items-center gap-3 mt-4">
<span class="text-xs text-base-content/40 text-center sm:text-left"> <span class="text-xs text-base-content/60 text-center sm:text-left">
Address information appears on invoices and quotes Address information appears on invoices and quotes
</span> </span>
@@ -234,10 +237,10 @@ const successType = url.searchParams.get('success');
<Icon name="tag" class="w-4 h-4" /> <Icon name="tag" class="w-4 h-4" />
Tags & Rates Tags & Rates
</h2> </h2>
<button onclick="document.getElementById('new_tag_modal').showModal()" class="btn btn-primary btn-xs"> <ModalButton client:load modalId="new_tag_modal" class="btn btn-primary btn-xs">
<Icon name="plus" class="w-3 h-3" /> <Icon name="plus" class="w-3 h-3" />
Add Tag Add Tag
</button> </ModalButton>
</div> </div>
<p class="text-base-content/60 text-xs mb-4"> <p class="text-base-content/60 text-xs mb-4">
@@ -268,7 +271,7 @@ const successType = url.searchParams.get('success');
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{tag.color && ( {tag.color && (
<div class="w-3 h-3 rounded-full" style={`background-color: ${tag.color}`}></div> <ColorDot client:load color={tag.color} class="w-3 h-3 rounded-full" />
)} )}
<span class="font-medium">{tag.name}</span> <span class="font-medium">{tag.name}</span>
</div> </div>
@@ -277,22 +280,23 @@ const successType = url.searchParams.get('success');
{tag.rate ? ( {tag.rate ? (
<span class="font-mono text-sm">{new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)}</span> <span class="font-mono text-sm">{new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)}</span>
) : ( ) : (
<span class="text-base-content/40 text-xs italic">No rate</span> <span class="text-base-content/60 text-xs italic">No rate</span>
)} )}
</td> </td>
<td> <td>
<div class="flex gap-1"> <div class="flex gap-1">
<button <ModalButton
onclick={`document.getElementById('edit_tag_modal_${tag.id}').showModal()`} client:load
modalId={`edit_tag_modal_${tag.id}`}
class="btn btn-ghost btn-xs btn-square" class="btn btn-ghost btn-xs btn-square"
> >
<Icon name="pencil" class="w-3 h-3" /> <Icon name="pencil" class="w-3 h-3" />
</button> </ModalButton>
<form method="POST" action={`/api/tags/${tag.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this tag?');"> <ConfirmForm client:load message="Are you sure you want to delete this tag?" action={`/api/tags/${tag.id}/delete`}>
<button class="btn btn-ghost btn-xs btn-square text-error"> <button class="btn btn-ghost btn-xs btn-square text-error">
<Icon name="trash" class="w-3 h-3" /> <Icon name="trash" class="w-3 h-3" />
</button> </button>
</form> </ConfirmForm>
</div> </div>
{/* Edit Modal */} {/* Edit Modal */}
@@ -311,10 +315,10 @@ const successType = url.searchParams.get('success');
<fieldset class="fieldset mb-4"> <fieldset class="fieldset mb-4">
<legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend> <legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend>
<input type="number" name="rate" value={tag.rate || 0} min="0" class="input w-full" /> <input type="number" name="rate" value={tag.rate || 0} min="0" class="input w-full" />
<p class="text-xs text-base-content/40 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p> <p class="text-xs text-base-content/60 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p>
</fieldset> </fieldset>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-sm" onclick={`document.getElementById('edit_tag_modal_${tag.id}').close()`}>Cancel</button> <ModalButton client:load modalId={`edit_tag_modal_${tag.id}`} action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">Save</button> <button type="submit" class="btn btn-primary btn-sm">Save</button>
</div> </div>
</form> </form>
@@ -349,10 +353,10 @@ const successType = url.searchParams.get('success');
<fieldset class="fieldset mb-4"> <fieldset class="fieldset mb-4">
<legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend> <legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend>
<input type="number" name="rate" value="0" min="0" class="input w-full" /> <input type="number" name="rate" value="0" min="0" class="input w-full" />
<p class="text-xs text-base-content/40 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p> <p class="text-xs text-base-content/60 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p>
</fieldset> </fieldset>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-sm" onclick="document.getElementById('new_tag_modal').close()">Cancel</button> <ModalButton client:load modalId="new_tag_modal" action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">Create Tag</button> <button type="submit" class="btn btn-primary btn-sm">Create Tag</button>
</div> </div>
</form> </form>
@@ -363,5 +367,4 @@ const successType = url.searchParams.get('success');
</dialog> </dialog>
</DashboardLayout> </DashboardLayout>

View File

@@ -3,6 +3,8 @@ import DashboardLayout from '../../layouts/DashboardLayout.astro';
import Icon from '../../components/Icon.astro'; import Icon from '../../components/Icon.astro';
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 AutoSubmit from '../../components/AutoSubmit.vue';
import ConfirmForm from '../../components/ConfirmForm.vue';
import { db } from '../../db'; import { db } from '../../db';
import { timeEntries, clients, 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';
@@ -142,9 +144,9 @@ const paginationPages = getPaginationPages(page, totalPages);
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Time Tracker</h1> <h1 class="text-2xl font-extrabold tracking-tight mb-6">Time Tracker</h1>
<!-- Tabs for Timer and Manual Entry --> <!-- Tabs for Timer and Manual Entry -->
<div class="tabs tabs-lift mb-6"> <div class="tabs tabs-border mb-6">
<input type="radio" name="tracker_tabs" class="tab" aria-label="Timer" checked="checked" /> <input type="radio" name="tracker_tabs" class="tab" aria-label="Timer" checked="checked" />
<div class="tab-content bg-base-100 border-base-300 p-6"> <div class="tab-content border-base-content/20 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">
<Icon name="exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" /> <Icon name="exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
@@ -167,7 +169,7 @@ const paginationPages = getPaginationPages(page, totalPages);
</div> </div>
<input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" /> <input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" />
<div class="tab-content bg-base-100 border-base-300 p-6"> <div class="tab-content border-base-content/20 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">
<Icon name="exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" /> <Icon name="exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
@@ -206,42 +208,50 @@ const paginationPages = getPaginationPages(page, totalPages);
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Client</legend> <legend class="fieldset-legend text-xs">Client</legend>
<select id="tracker-client" name="client" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="">All Clients</option> <select id="tracker-client" name="client" class="select w-full">
{allClients.map(client => ( <option value="">All Clients</option>
<option value={client.id} selected={filterClient === client.id}> {allClients.map(client => (
{client.name} <option value={client.id} selected={filterClient === client.id}>
</option> {client.name}
))} </option>
</select> ))}
</select>
</AutoSubmit>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Status</legend> <legend class="fieldset-legend text-xs">Status</legend>
<select id="tracker-status" name="status" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="" selected={filterStatus === ''}>All Entries</option> <select id="tracker-status" name="status" class="select w-full">
<option value="completed" selected={filterStatus === 'completed'}>Completed</option> <option value="" selected={filterStatus === ''}>All Entries</option>
<option value="running" selected={filterStatus === 'running'}>Running</option> <option value="completed" selected={filterStatus === 'completed'}>Completed</option>
</select> <option value="running" selected={filterStatus === 'running'}>Running</option>
</select>
</AutoSubmit>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Entry Type</legend> <legend class="fieldset-legend text-xs">Entry Type</legend>
<select id="tracker-type" name="type" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="" selected={filterType === ''}>All Types</option> <select id="tracker-type" name="type" class="select w-full">
<option value="timed" selected={filterType === 'timed'}>Timed</option> <option value="" selected={filterType === ''}>All Types</option>
<option value="manual" selected={filterType === 'manual'}>Manual</option> <option value="timed" selected={filterType === 'timed'}>Timed</option>
</select> <option value="manual" selected={filterType === 'manual'}>Manual</option>
</select>
</AutoSubmit>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Sort By</legend> <legend class="fieldset-legend text-xs">Sort By</legend>
<select id="tracker-sort" name="sort" class="select w-full" onchange="this.form.submit()"> <AutoSubmit client:load>
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option> <select id="tracker-sort" name="sort" class="select w-full">
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option> <option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option> <option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
<option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option> <option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
</select> <option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option>
</select>
</AutoSubmit>
</fieldset> </fieldset>
<input type="hidden" name="page" value="1" /> <input type="hidden" name="page" value="1" />
@@ -304,7 +314,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<td>{entryUser?.name || 'Unknown'}</td> <td>{entryUser?.name || 'Unknown'}</td>
<td class="whitespace-nowrap"> <td class="whitespace-nowrap">
{entry.startTime.toLocaleDateString()}<br/> {entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs text-base-content/40"> <span class="text-xs text-base-content/60">
{entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} {entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span> </span>
</td> </td>
@@ -312,7 +322,7 @@ const paginationPages = getPaginationPages(page, totalPages);
{entry.endTime ? ( {entry.endTime ? (
<> <>
{entry.endTime.toLocaleDateString()}<br/> {entry.endTime.toLocaleDateString()}<br/>
<span class="text-xs text-base-content/40"> <span class="text-xs text-base-content/60">
{entry.endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} {entry.endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span> </span>
</> </>
@@ -322,15 +332,14 @@ const paginationPages = getPaginationPages(page, totalPages);
</td> </td>
<td class="font-mono font-semibold text-primary text-sm">{formatTimeRange(entry.startTime, entry.endTime)}</td> <td class="font-mono font-semibold text-primary text-sm">{formatTimeRange(entry.startTime, entry.endTime)}</td>
<td> <td>
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline"> <ConfirmForm client:load message="Are you sure you want to delete this entry?" action={`/api/time-entries/${entry.id}/delete`} class="inline">
<button <button
type="submit" type="submit"
class="btn btn-ghost btn-xs text-error" class="btn btn-ghost btn-xs text-error"
onclick="return confirm('Are you sure you want to delete this entry?')"
> >
<Icon name="trash" class="w-3.5 h-3.5" /> <Icon name="trash" class="w-3.5 h-3.5" />
</button> </button>
</form> </ConfirmForm>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -31,11 +31,11 @@ if (Astro.locals.user) {
</div> </div>
<!-- Features --> <!-- Features -->
<div class="bg-base-200/50 border-t border-base-200 px-4 py-16 sm:py-20"> <div class="bg-base-200 border-t border-base-content/20 px-4 py-16 sm:py-20">
<div class="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card bg-base-100 card-border"> <div class="card bg-base-100 card-border">
<div class="card-body"> <div class="card-body">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-2"> <div class="w-10 h-10 rounded-lg bg-base-200 flex items-center justify-center mb-2">
<Icon name="bolt" class="h-5 w-5 text-primary" /> <Icon name="bolt" class="h-5 w-5 text-primary" />
</div> </div>
<h3 class="card-title text-base">Lightning Fast</h3> <h3 class="card-title text-base">Lightning Fast</h3>
@@ -44,7 +44,7 @@ if (Astro.locals.user) {
</div> </div>
<div class="card bg-base-100 card-border"> <div class="card bg-base-100 card-border">
<div class="card-body"> <div class="card-body">
<div class="w-10 h-10 rounded-lg bg-info/10 flex items-center justify-center mb-2"> <div class="w-10 h-10 rounded-lg bg-base-200 flex items-center justify-center mb-2">
<Icon name="chart-bar" class="h-5 w-5 text-info" /> <Icon name="chart-bar" class="h-5 w-5 text-info" />
</div> </div>
<h3 class="card-title text-base">Detailed Reports</h3> <h3 class="card-title text-base">Detailed Reports</h3>
@@ -53,7 +53,7 @@ if (Astro.locals.user) {
</div> </div>
<div class="card bg-base-100 card-border"> <div class="card bg-base-100 card-border">
<div class="card-body"> <div class="card-body">
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center mb-2"> <div class="w-10 h-10 rounded-lg bg-base-200 flex items-center justify-center mb-2">
<Icon name="users" class="h-5 w-5 text-accent" /> <Icon name="users" class="h-5 w-5 text-accent" />
</div> </div>
<h3 class="card-title text-base">Team Collaboration</h3> <h3 class="card-title text-base">Team Collaboration</h3>

View File

@@ -18,7 +18,7 @@ const errorMessage =
<Layout title="Login - Chronus"> <Layout title="Login - Chronus">
<div class="flex justify-center items-center flex-1 bg-base-100"> <div class="flex justify-center items-center flex-1 bg-base-100">
<div class="card card-border bg-base-100 w-full max-w-sm mx-4"> <div class="card card-border bg-base-200 w-full max-w-sm mx-4">
<div class="card-body gap-0"> <div class="card-body gap-0">
<img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" /> <img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" />
<h2 class="text-2xl font-extrabold tracking-tight text-center">Welcome Back</h2> <h2 class="text-2xl font-extrabold tracking-tight text-center">Welcome Back</h2>

View File

@@ -34,7 +34,7 @@ const errorMessage =
<Layout title="Sign Up - Chronus"> <Layout title="Sign Up - Chronus">
<div class="flex justify-center items-center flex-1 bg-base-100"> <div class="flex justify-center items-center flex-1 bg-base-100">
<div class="card card-border bg-base-100 w-full max-w-sm mx-4"> <div class="card card-border bg-base-200 w-full max-w-sm mx-4">
<div class="card-body gap-0"> <div class="card-body gap-0">
<img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" /> <img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" />
<h2 class="text-2xl font-extrabold tracking-tight text-center">Create Account</h2> <h2 class="text-2xl font-extrabold tracking-tight text-center">Create Account</h2>

View File

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

View File

@@ -305,7 +305,6 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
if (organization.logoUrl) { if (organization.logoUrl) {
try { try {
let logoPath; let logoPath;
// Handle uploads directory which might be external to public/
if (organization.logoUrl.startsWith("/uploads/")) { if (organization.logoUrl.startsWith("/uploads/")) {
const dataDir = process.env.DATA_DIR const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR ? process.env.DATA_DIR

View File

@@ -1,9 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui" { @plugin "daisyui" {
themes: false; themes: sunset --default, winter;
} }
@plugin "./theme-dark.ts";
@plugin "./theme-light.ts";
/* Smoother transitions globally */ /* Smoother transitions globally */
@layer base { @layer base {
@@ -15,3 +13,8 @@
transition: none; transition: none;
} }
} }
/* Override DaisyUI card-border to use a visible border in both themes */
.card-border {
border-color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
}

View File

@@ -1,9 +0,0 @@
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
export default createCatppuccinPlugin(
"macchiato",
{},
{
default: true,
},
);

View File

@@ -1,9 +0,0 @@
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
export default createCatppuccinPlugin(
"latte",
{},
{
default: false,
},
);

View File

@@ -34,7 +34,6 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
if (discountType === "percentage") { if (discountType === "percentage") {
discountAmount = Math.round(subtotal * (discountValue / 100)); discountAmount = Math.round(subtotal * (discountValue / 100));
} else { } else {
// Fixed amount is assumed to be in cents
discountAmount = Math.round(discountValue); discountAmount = Math.round(discountValue);
} }