6 Commits
2.0.0 ... 2.2.0

Author SHA1 Message Date
d4a2c5853b 2.2.0 Migrations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m30s
2026-01-19 19:41:56 -07:00
ee9807e8e0 Passkeys!
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-19 15:53:05 -07:00
bf2a1816db Added custom ranges for report filtering + CSV exports
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m59s
2026-01-19 15:18:34 -07:00
1063bf99f1 2.1.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m24s
2026-01-19 10:06:23 -07:00
ea0a83f44d Added discounts to invoices
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-19 10:06:04 -07:00
fa2c92644a Forgot these...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m4s
2026-01-18 14:57:32 -07:00
32 changed files with 5569 additions and 591 deletions

View File

@@ -0,0 +1,16 @@
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
CREATE INDEX `categories_organization_id_idx` ON `categories` (`organization_id`);--> statement-breakpoint
CREATE INDEX `clients_organization_id_idx` ON `clients` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invoice_items_invoice_id_idx` ON `invoice_items` (`invoice_id`);--> statement-breakpoint
CREATE INDEX `invoices_organization_id_idx` ON `invoices` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invoices_client_id_idx` ON `invoices` (`client_id`);--> statement-breakpoint
CREATE INDEX `members_user_id_idx` ON `members` (`user_id`);--> statement-breakpoint
CREATE INDEX `members_organization_id_idx` ON `members` (`organization_id`);--> statement-breakpoint
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `tags_organization_id_idx` ON `tags` (`organization_id`);--> statement-breakpoint
CREATE INDEX `time_entries_user_id_idx` ON `time_entries` (`user_id`);--> statement-breakpoint
CREATE INDEX `time_entries_organization_id_idx` ON `time_entries` (`organization_id`);--> statement-breakpoint
CREATE INDEX `time_entries_client_id_idx` ON `time_entries` (`client_id`);--> statement-breakpoint
CREATE INDEX `time_entries_start_time_idx` ON `time_entries` (`start_time`);--> statement-breakpoint
CREATE INDEX `time_entry_tags_time_entry_id_idx` ON `time_entry_tags` (`time_entry_id`);--> statement-breakpoint
CREATE INDEX `time_entry_tags_tag_id_idx` ON `time_entry_tags` (`tag_id`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `invoices` ADD `discount_value` real DEFAULT 0;--> statement-breakpoint
ALTER TABLE `invoices` ADD `discount_type` text DEFAULT 'percentage';--> statement-breakpoint
ALTER TABLE `invoices` ADD `discount_amount` integer DEFAULT 0;

View File

@@ -0,0 +1,22 @@
CREATE TABLE `passkey_challenges` (
`id` text PRIMARY KEY NOT NULL,
`challenge` text NOT NULL,
`user_id` text,
`expires_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `passkey_challenges_challenge_unique` ON `passkey_challenges` (`challenge`);--> statement-breakpoint
CREATE TABLE `passkeys` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`public_key` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`last_used_at` integer,
`created_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `passkeys_user_id_idx` ON `passkeys` (`user_id`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,27 @@
"when": 1768690333269,
"tag": "0001_lazy_roughhouse",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1768773436601,
"tag": "0002_chilly_cyclops",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1768842088321,
"tag": "0003_amusing_wendigo",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1768876902359,
"tag": "0004_happy_namorita",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,7 @@
{
"name": "chronus",
"type": "module",
"version": "2.0.0",
"version": "2.2.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
@@ -18,17 +18,20 @@
"@ceereals/vue-pdf": "^0.2.1",
"@iconify/vue": "^5.0.0",
"@libsql/client": "^0.17.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.16.11",
"astro-icon": "^1.1.5",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"daisyui": "^5.5.14",
"dotenv": "^17.2.3",
"drizzle-orm": "0.45.1",
"nanoid": "^5.1.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vue": "^3.5.26",
"vue": "^3.5.27",
"vue-chartjs": "^5.3.3"
},
"devDependencies": {

647
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
<template>
<div style="position: relative; height: 100%; width: 100%;">
<div style="position: relative; height: 100%; width: 100%">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import { computed } from "vue";
import { Bar } from "vue-chartjs";
import {
Chart as ChartJS,
BarElement,
@@ -14,10 +14,18 @@ import {
LinearScale,
Tooltip,
Legend,
BarController
} from 'chart.js';
BarController,
type ChartOptions,
} from "chart.js";
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
ChartJS.register(
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
BarController,
);
interface MemberData {
name: string;
@@ -29,47 +37,49 @@ const props = defineProps<{
}>();
const chartData = computed(() => ({
labels: props.members.map(m => m.name),
datasets: [{
label: 'Time Tracked',
data: props.members.map(m => m.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: '#10b981',
borderColor: '#059669',
labels: props.members.map((m) => m.name),
datasets: [
{
label: "Time Tracked",
data: props.members.map((m) => m.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: "#10b981",
borderColor: "#059669",
borderWidth: 1,
}]
},
],
}));
const chartOptions = {
indexAxis: 'y' as const,
const chartOptions: ChartOptions<"bar"> = {
indexAxis: "y" as const,
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
ticks: {
color: '#e2e8f0',
callback: function(value: number) {
color: "#e2e8f0",
callback: function (value: any) {
const hours = Math.floor(value / 60);
const mins = value % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
},
},
grid: {
color: '#334155'
}
color: "#334155",
},
},
y: {
ticks: {
color: '#e2e8f0'
color: "#e2e8f0",
},
grid: {
display: false
}
}
display: false,
},
},
},
plugins: {
legend: {
display: false
display: false,
},
tooltip: {
callbacks: {
@@ -78,9 +88,9 @@ const chartOptions = {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
}
}
}
}
},
},
},
},
};
</script>

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
interface ApiToken {
id: string;
name: string;
lastUsedAt: string | null;
createdAt: string;
}
const props = defineProps<{
initialTokens: ApiToken[];
}>();
const tokens = ref<ApiToken[]>(props.initialTokens);
const createModalOpen = ref(false);
const showTokenModalOpen = ref(false);
const newTokenName = ref('');
const newTokenValue = ref('');
const loading = ref(false);
function formatDate(dateString: string | null) {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString();
}
async function createToken() {
if (!newTokenName.value) return;
loading.value = true;
try {
const response = await fetch('/api/user/tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newTokenName.value }),
});
if (response.ok) {
const data = await response.json();
const { token, ...tokenMeta } = data;
// Add to beginning of list
tokens.value.unshift({
id: tokenMeta.id,
name: tokenMeta.name,
lastUsedAt: tokenMeta.lastUsedAt,
createdAt: tokenMeta.createdAt
});
newTokenValue.value = token;
createModalOpen.value = false;
showTokenModalOpen.value = true;
newTokenName.value = '';
} else {
alert('Failed to create token');
}
} catch (error) {
console.error('Error creating token:', error);
alert('An error occurred');
} finally {
loading.value = false;
}
}
async function deleteToken(id: string) {
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) {
return;
}
try {
const response = await fetch(`/api/user/tokens/${id}`, {
method: 'DELETE'
});
if (response.ok) {
tokens.value = tokens.value.filter(t => t.id !== id);
} else {
alert('Failed to delete token');
}
} catch (error) {
console.error('Error deleting token:', error);
alert('An error occurred');
}
}
function copyToken() {
navigator.clipboard.writeText(newTokenValue.value);
}
function closeShowTokenModal() {
showTokenModalOpen.value = false;
newTokenValue.value = '';
}
</script>
<template>
<div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl">
<Icon icon="heroicons:code-bracket-square" class="w-5 h-5 sm:w-6 sm:h-6" />
API Tokens
</h2>
<button class="btn btn-primary btn-sm" @click="createModalOpen = true">
<Icon icon="heroicons:plus" class="w-4 h-4" />
Create Token
</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Last Used</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="tokens.length === 0">
<td colspan="4" class="text-center text-base-content/60 py-4">
No API tokens found. Create one to access the API.
</td>
</tr>
<tr v-else v-for="token in tokens" :key="token.id">
<td class="font-medium">{{ token.name }}</td>
<td class="text-sm">
{{ formatDate(token.lastUsedAt) }}
</td>
<td class="text-sm">
{{ formatDate(token.createdAt) }}
</td>
<td>
<button
class="btn btn-ghost btn-xs text-error"
@click="deleteToken(token.id)"
>
<Icon icon="heroicons:trash" class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Create Token Modal -->
<dialog class="modal" :class="{ 'modal-open': createModalOpen }">
<div class="modal-box">
<h3 class="font-bold text-lg">Create API Token</h3>
<p class="py-4 text-sm text-base-content/70">
API tokens allow you to authenticate with the API programmatically.
Give your token a descriptive name.
</p>
<form @submit.prevent="createToken" class="space-y-4">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Token Name</span>
</label>
<input
type="text"
v-model="newTokenName"
placeholder="e.g. CI/CD Pipeline"
class="input input-bordered w-full"
required
/>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="createModalOpen = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
Generate Token
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop" @click="createModalOpen = false">
<button>close</button>
</form>
</dialog>
<!-- Show Token Modal -->
<dialog class="modal" :class="{ 'modal-open': showTokenModalOpen }">
<div class="modal-box">
<h3 class="font-bold text-lg text-success flex items-center gap-2">
<Icon icon="heroicons:check-circle" class="w-6 h-6" />
Token Created
</h3>
<p class="py-4">
Make sure to copy your personal access token now. You won't be able to see it again!
</p>
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group">
<span>{{ newTokenValue }}</span>
<button
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
@click="copyToken"
title="Copy to clipboard"
>
<Icon icon="heroicons:clipboard" class="w-4 h-4" />
</button>
</div>
<div class="modal-action">
<button class="btn btn-primary" @click="closeShowTokenModal">Done</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" @click="closeShowTokenModal">
<button>close</button>
</form>
</dialog>
</div>
</template>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
import { startRegistration } from '@simplewebauthn/browser';
interface Passkey {
id: string;
deviceType: string;
backedUp: boolean;
lastUsedAt: string | null;
createdAt: string | null;
}
const props = defineProps<{
initialPasskeys: Passkey[];
}>();
const passkeys = ref<Passkey[]>(props.initialPasskeys);
const loading = ref(false);
function formatDate(dateString: string | null) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString();
}
async function registerPasskey() {
loading.value = true;
try {
// 1. Get options from server
const resp = await fetch("/api/auth/passkey/register/start");
if (!resp.ok) {
throw new Error("Failed to start registration");
}
const options = await resp.json();
// 2. Browser handles interaction
let attResp;
try {
attResp = await startRegistration(options);
} catch (error) {
if ((error as any).name === 'NotAllowedError') {
// User cancelled or timed out
return;
}
console.error(error);
alert('Failed to register passkey: ' + (error as any).message);
return;
}
// 3. Verify with server
const verificationResp = await fetch(
"/api/auth/passkey/register/finish",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(attResp),
}
);
const verificationJSON = await verificationResp.json();
if (verificationJSON.verified) {
// Reload to show the new passkey since the API doesn't return the created object
// Ideally we would return the object and append it to 'passkeys' to avoid reload
window.location.reload();
} else {
alert("Passkey registration failed");
}
} catch (error) {
console.error('Error registering passkey:', error);
alert('An error occurred');
} finally {
loading.value = false;
}
}
async function deletePasskey(id: string) {
if (!confirm('Are you sure you want to remove this passkey?')) {
return;
}
try {
const response = await fetch(`/api/auth/passkey/delete?id=${id}`, {
method: 'DELETE'
});
if (response.ok) {
// Optimistically remove from list
passkeys.value = passkeys.value.filter(pk => pk.id !== id);
} else {
alert('Failed to delete passkey');
}
} catch (error) {
console.error('Error deleting passkey:', error);
alert('An error occurred');
}
}
</script>
<template>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl">
<Icon icon="heroicons:finger-print" class="w-5 h-5 sm:w-6 sm:h-6" />
Passkeys
</h2>
<button class="btn btn-primary btn-sm" @click="registerPasskey" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
<Icon v-else icon="heroicons:plus" class="w-4 h-4" />
Add Passkey
</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Device Type</th>
<th>Last Used</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="passkeys.length === 0">
<td colspan="4" class="text-center text-base-content/60 py-4">
No passkeys found. Add one to sign in without a password.
</td>
</tr>
<tr v-else v-for="pk in passkeys" :key="pk.id">
<td class="font-medium">
{{ pk.deviceType === 'singleDevice' ? 'This Device' : 'Cross-Platform (Phone/Key)' }}
<span v-if="pk.backedUp" class="badge badge-xs badge-info ml-2">Backed Up</span>
</td>
<td class="text-sm">
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : 'Never' }}
</td>
<td class="text-sm">
{{ formatDate(pk.createdAt) }}
</td>
<td>
<button
class="btn btn-ghost btn-xs text-error"
@click="deletePasskey(pk.id)"
>
<Icon icon="heroicons:trash" class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const loading = ref(false);
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
async function changePassword() {
if (newPassword.value !== confirmPassword.value) {
message.value = { type: 'error', text: 'New passwords do not match' };
return;
}
if (newPassword.value.length < 8) {
message.value = { type: 'error', text: 'Password must be at least 8 characters' };
return;
}
loading.value = true;
message.value = null;
try {
const response = await fetch('/api/user/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
currentPassword: currentPassword.value,
newPassword: newPassword.value,
confirmPassword: confirmPassword.value,
}),
});
if (response.ok) {
message.value = { type: 'success', text: 'Password changed successfully!' };
// Reset form
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
// Hide success message after 3 seconds
setTimeout(() => {
message.value = null;
}, 3000);
} else {
const data = await response.json().catch(() => ({}));
message.value = { type: 'error', text: data.error || 'Failed to change password' };
}
} catch (error) {
message.value = { type: 'error', text: 'An error occurred' };
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<!-- Success/Error Message Display -->
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']">
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" />
<span>{{ message.text }}</span>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon icon="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
Change Password
</h2>
<form @submit.prevent="changePassword" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
</label>
<input
type="password"
v-model="currentPassword"
placeholder="Enter current password"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
</label>
<input
type="password"
v-model="newPassword"
placeholder="Enter new password"
class="input input-bordered w-full"
required
minlength="8"
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
</div>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
</label>
<input
type="password"
v-model="confirmPassword"
placeholder="Confirm new password"
class="input input-bordered w-full"
required
minlength="8"
/>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<Icon v-else icon="heroicons:lock-closed" class="w-5 h-5" />
Update Password
</button>
</div>
</form>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
const props = defineProps<{
user: {
id: string;
name: string;
email: string;
};
}>();
const name = ref(props.user.name);
const loading = ref(false);
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
async function updateProfile() {
loading.value = true;
message.value = null;
try {
const response = await fetch('/api/user/update-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: name.value }),
});
if (response.ok) {
message.value = { type: 'success', text: 'Profile updated successfully!' };
// Hide success message after 3 seconds
setTimeout(() => {
message.value = null;
}, 3000);
} else {
const data = await response.json().catch(() => ({}));
message.value = { type: 'error', text: data.error || 'Failed to update profile' };
}
} catch (error) {
message.value = { type: 'error', text: 'An error occurred' };
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<!-- Success/Error Message Display -->
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']">
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" />
<span>{{ message.text }}</span>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon icon="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
Profile Information
</h2>
<form @submit.prevent="updateProfile" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
</label>
<input
type="text"
v-model="name"
placeholder="Your full name"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Email</span>
</label>
<input
type="email"
:value="props.user.email"
class="input input-bordered w-full"
disabled
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<Icon v-else icon="heroicons:check" class="w-5 h-5" />
Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</template>

View File

@@ -277,6 +277,9 @@ export const invoices = sqliteTable(
notes: text("notes"),
currency: text("currency").default("USD").notNull(),
subtotal: integer("subtotal").notNull().default(0), // in cents
discountValue: real("discount_value").default(0),
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed'
discountAmount: integer("discount_amount").default(0), // in cents
taxRate: real("tax_rate").default(0), // percentage
taxAmount: integer("tax_amount").notNull().default(0), // in cents
total: integer("total").notNull().default(0), // in cents
@@ -320,3 +323,36 @@ export const invoiceItems = sqliteTable(
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
}),
);
export const passkeys = sqliteTable(
"passkeys",
{
id: text("id").primaryKey(), // The Credential ID
userId: text("user_id").notNull(),
publicKey: text("public_key").notNull(), // Base64 encoded public key
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(), // 'singleDevice' or 'multiDevice'
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"), // JSON stringified array
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
},
(table: any) => ({
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
}),
userIdIdx: index("passkeys_user_id_idx").on(table.userId),
}),
);
export const passkeyChallenges = sqliteTable("passkey_challenges", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
challenge: text("challenge").notNull().unique(),
userId: text("user_id"),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
});

View File

@@ -0,0 +1,35 @@
import type { APIRoute } from "astro";
import { db } from "../../../../../db";
import { passkeys } from "../../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const DELETE: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
const url = new URL(request.url);
const id = url.searchParams.get("id");
if (!id) {
return new Response(JSON.stringify({ error: "Passkey ID is required" }), {
status: 400,
});
}
try {
await db
.delete(passkeys)
.where(and(eq(passkeys.id, id), eq(passkeys.userId, user.id)));
return new Response(JSON.stringify({ success: true }));
} catch (error) {
return new Response(JSON.stringify({ error: "Failed to delete passkey" }), {
status: 500,
});
}
};

View File

@@ -0,0 +1,102 @@
import type { APIRoute } from "astro";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { users, passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq, and, gt } from "drizzle-orm";
import { createSession } from "../../../../../lib/auth";
export const POST: APIRoute = async ({ request, cookies }) => {
const body = await request.json();
const { id } = body;
const passkey = await db.query.passkeys.findFirst({
where: eq(passkeys.id, id),
});
if (!passkey) {
return new Response(JSON.stringify({ error: "Passkey not found" }), {
status: 400,
});
}
const user = await db.query.users.findFirst({
where: eq(users.id, passkey.userId),
});
if (!user) return new Response(null, { status: 400 });
const clientDataJSON = Buffer.from(
body.response.clientDataJSON,
"base64url",
).toString("utf-8");
const clientData = JSON.parse(clientDataJSON);
const challenge = clientData.challenge;
const dbChallenge = await db.query.passkeyChallenges.findFirst({
where: and(
eq(passkeyChallenges.challenge, challenge),
gt(passkeyChallenges.expiresAt, new Date()),
),
});
if (!dbChallenge) {
return new Response(
JSON.stringify({ error: "Invalid or expired challenge" }),
{
status: 400,
},
);
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: challenge as string,
expectedOrigin: new URL(request.url).origin,
expectedRPID: new URL(request.url).hostname,
credential: {
id: passkey.id,
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")),
counter: passkey.counter,
transports: passkey.transports
? JSON.parse(passkey.transports)
: undefined,
},
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 400,
});
}
if (verification.verified) {
const { authenticationInfo } = verification;
await db
.update(passkeys)
.set({
counter: authenticationInfo.newCounter,
lastUsedAt: new Date(),
})
.where(eq(passkeys.id, passkey.id));
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
await db
.delete(passkeyChallenges)
.where(eq(passkeyChallenges.challenge, challenge));
return new Response(JSON.stringify({ verified: true }));
}
return new Response(JSON.stringify({ verified: false }), { status: 400 });
};

View File

@@ -0,0 +1,18 @@
import type { APIRoute } from "astro";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeyChallenges } from "../../../../../db/schema";
export const GET: APIRoute = async ({ request }) => {
const options = await generateAuthenticationOptions({
rpID: new URL(request.url).hostname,
userVerification: "preferred",
});
await db.insert(passkeyChallenges).values({
challenge: options.challenge,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
return new Response(JSON.stringify(options));
};

View File

@@ -0,0 +1,81 @@
import type { APIRoute } from "astro";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq, and, gt } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
const body = await request.json();
const clientDataJSON = Buffer.from(
body.response.clientDataJSON,
"base64url",
).toString("utf-8");
const clientData = JSON.parse(clientDataJSON);
const challenge = clientData.challenge;
const dbChallenge = await db.query.passkeyChallenges.findFirst({
where: and(
eq(passkeyChallenges.challenge, challenge),
eq(passkeyChallenges.userId, user.id),
gt(passkeyChallenges.expiresAt, new Date()),
),
});
if (!dbChallenge) {
return new Response(
JSON.stringify({ error: "Invalid or expired challenge" }),
{
status: 400,
},
);
}
let verification;
try {
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: challenge,
expectedOrigin: new URL(request.url).origin,
expectedRPID: new URL(request.url).hostname,
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 400,
});
}
if (verification.verified && verification.registrationInfo) {
const { registrationInfo } = verification;
const { credential, credentialDeviceType, credentialBackedUp } =
registrationInfo;
await db.insert(passkeys).values({
id: credential.id,
userId: user.id,
publicKey: Buffer.from(credential.publicKey).toString("base64"),
counter: credential.counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: body.response.transports
? JSON.stringify(body.response.transports)
: undefined,
});
await db
.delete(passkeyChallenges)
.where(eq(passkeyChallenges.challenge, challenge));
return new Response(JSON.stringify({ verified: true }));
}
return new Response(JSON.stringify({ verified: false }), { status: 400 });
};

View File

@@ -0,0 +1,45 @@
import type { APIRoute } from "astro";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq } from "drizzle-orm";
export const GET: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
// Get user's existing passkeys to prevent registering the same authenticator twice
const userPasskeys = await db.query.passkeys.findMany({
where: eq(passkeys.userId, user.id),
});
const options = await generateRegistrationOptions({
rpName: "Chronus",
rpID: new URL(request.url).hostname,
userName: user.email,
attestationType: "none",
excludeCredentials: userPasskeys.map((passkey) => ({
id: passkey.id,
transports: passkey.transports
? JSON.parse(passkey.transports)
: undefined,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
await db.insert(passkeyChallenges).values({
challenge: options.challenge,
userId: user.id,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
return new Response(JSON.stringify(options));
};

View File

@@ -4,12 +4,7 @@ import { invoices, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
export const POST: APIRoute = async ({
request,
redirect,
locals,
params,
}) => {
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
const user = locals.user;
if (!user) {
return redirect("/login");
@@ -38,8 +33,8 @@ export const POST: APIRoute = async ({
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
eq(members.organizationId, invoice.organizationId),
),
)
.get();
@@ -53,6 +48,8 @@ export const POST: APIRoute = async ({
const issueDateStr = formData.get("issueDate") as string;
const dueDateStr = formData.get("dueDate") as string;
const taxRateStr = formData.get("taxRate") as string;
const discountType = (formData.get("discountType") as string) || "percentage";
const discountValueStr = formData.get("discountValue") as string;
const notes = formData.get("notes") as string;
if (!number || !currency || !issueDateStr || !dueDateStr) {
@@ -64,6 +61,11 @@ export const POST: APIRoute = async ({
const dueDate = new Date(dueDateStr);
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
let discountValue = discountValueStr ? parseFloat(discountValueStr) : 0;
if (discountType === "fixed") {
discountValue = Math.round(discountValue * 100);
}
await db
.update(invoices)
.set({
@@ -72,6 +74,8 @@ export const POST: APIRoute = async ({
issueDate,
dueDate,
taxRate,
discountType: discountType as "percentage" | "fixed",
discountValue,
notes: notes || null,
})
.where(eq(invoices.id, invoiceId));

View File

@@ -0,0 +1,137 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, users, clients, categories } from '../../../db/schema';
import { eq, and, gte, lte, desc } from 'drizzle-orm';
export const GET: APIRoute = async ({ request, locals, cookies }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Get current team from cookie
const currentTeamId = cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) {
return new Response('No organization found', { status: 404 });
}
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const url = new URL(request.url);
const selectedMemberId = url.searchParams.get('member') || '';
const selectedCategoryId = url.searchParams.get('category') || '';
const selectedClientId = url.searchParams.get('client') || '';
const timeRange = url.searchParams.get('range') || 'week';
const customFrom = url.searchParams.get('from');
const customTo = url.searchParams.get('to');
const now = new Date();
let startDate = new Date();
let endDate = new Date();
switch (timeRange) {
case 'today':
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
break;
case 'week':
startDate.setDate(now.getDate() - 7);
break;
case 'month':
startDate.setMonth(now.getMonth() - 1);
break;
case 'mtd':
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case 'ytd':
startDate = new Date(now.getFullYear(), 0, 1);
break;
case 'last-month':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
break;
case 'custom':
if (customFrom) {
const parts = customFrom.split('-');
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
}
if (customTo) {
const parts = customTo.split('-');
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
}
break;
}
const conditions = [
eq(timeEntries.organizationId, userMembership.organizationId),
gte(timeEntries.startTime, startDate),
lte(timeEntries.startTime, endDate),
];
if (selectedMemberId) {
conditions.push(eq(timeEntries.userId, selectedMemberId));
}
if (selectedCategoryId) {
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
}
if (selectedClientId) {
conditions.push(eq(timeEntries.clientId, selectedClientId));
}
const entries = await db.select({
entry: timeEntries,
user: users,
client: clients,
category: categories,
})
.from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
.where(and(...conditions))
.orderBy(desc(timeEntries.startTime))
.all();
// Generate CSV
const headers = ['Date', 'Start Time', 'End Time', 'Duration (h)', 'Member', 'Client', 'Category', 'Description'];
const rows = entries.map(e => {
const start = e.entry.startTime;
const end = e.entry.endTime;
let duration = 0;
if (end) {
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
}
return [
start.toLocaleDateString(),
start.toLocaleTimeString(),
end ? end.toLocaleTimeString() : '',
end ? duration.toFixed(2) : 'Running',
`"${(e.user.name || '').replace(/"/g, '""')}"`,
`"${(e.client.name || '').replace(/"/g, '""')}"`,
`"${(e.category.name || '').replace(/"/g, '""')}"`,
`"${(e.entry.description || '').replace(/"/g, '""')}"`
].join(',');
});
const csvContent = [headers.join(','), ...rows].join('\n');
return new Response(csvContent, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="time-entries-${startDate.toISOString().split('T')[0]}-to-${endDate.toISOString().split('T')[0]}.csv"`,
},
});
};

View File

@@ -1,61 +1,104 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
const contentType = request.headers.get("content-type");
const isJson = contentType?.includes("application/json");
if (!user) {
return redirect('/login');
if (isJson) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
return redirect("/login");
}
let currentPassword, newPassword, confirmPassword;
if (isJson) {
const body = await request.json();
currentPassword = body.currentPassword;
newPassword = body.newPassword;
confirmPassword = body.confirmPassword;
} else {
const formData = await request.formData();
const currentPassword = formData.get('currentPassword') as string;
const newPassword = formData.get('newPassword') as string;
const confirmPassword = formData.get('confirmPassword') as string;
currentPassword = formData.get("currentPassword") as string;
newPassword = formData.get("newPassword") as string;
confirmPassword = formData.get("confirmPassword") as string;
}
if (!currentPassword || !newPassword || !confirmPassword) {
return new Response('All fields are required', { status: 400 });
const msg = "All fields are required";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
if (newPassword !== confirmPassword) {
return new Response('New passwords do not match', { status: 400 });
const msg = "New passwords do not match";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
if (newPassword.length < 8) {
return new Response('Password must be at least 8 characters', { status: 400 });
const msg = "Password must be at least 8 characters";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
try {
// Get current user from database
const dbUser = await db.select()
const dbUser = await db
.select()
.from(users)
.where(eq(users.id, user.id))
.get();
if (!dbUser) {
return new Response('User not found', { status: 404 });
const msg = "User not found";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 404 });
return new Response(msg, { status: 404 });
}
// Verify current password
const passwordMatch = await bcrypt.compare(currentPassword, dbUser.passwordHash);
const passwordMatch = await bcrypt.compare(
currentPassword,
dbUser.passwordHash,
);
if (!passwordMatch) {
return new Response('Current password is incorrect', { status: 400 });
const msg = "Current password is incorrect";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password
await db.update(users)
await db
.update(users)
.set({ passwordHash: hashedPassword })
.where(eq(users.id, user.id))
.run();
return redirect('/dashboard/settings?success=password');
if (isJson) {
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
return redirect("/dashboard/settings?success=password");
} catch (error) {
console.error('Error changing password:', error);
return new Response('Failed to change password', { status: 500 });
console.error("Error changing password:", error);
const msg = "Failed to change password";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 500 });
return new Response(msg, { status: 500 });
}
};

View File

@@ -12,8 +12,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
}
let name: string | undefined;
const contentType = request.headers.get("content-type");
if (contentType?.includes("application/json")) {
const body = await request.json();
name = body.name;
} else {
const formData = await request.formData();
const name = formData.get("name")?.toString();
name = formData.get("name")?.toString();
}
if (!name) {
return new Response(JSON.stringify({ error: "Name is required" }), {

View File

@@ -1,30 +1,58 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { eq } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
const contentType = request.headers.get("content-type");
const isJson = contentType?.includes("application/json");
if (!user) {
return redirect('/login');
if (isJson) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
return redirect("/login");
}
let name: string | undefined;
if (isJson) {
const body = await request.json();
name = body.name;
} else {
const formData = await request.formData();
const name = formData.get('name') as string;
name = formData.get("name") as string;
}
if (!name || name.trim().length === 0) {
return new Response('Name is required', { status: 400 });
const msg = "Name is required";
if (isJson) {
return new Response(JSON.stringify({ error: msg }), { status: 400 });
}
return new Response(msg, { status: 400 });
}
try {
await db.update(users)
await db
.update(users)
.set({ name: name.trim() })
.where(eq(users.id, user.id))
.run();
return redirect('/dashboard/settings?success=profile');
if (isJson) {
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
return redirect("/dashboard/settings?success=profile");
} catch (error) {
console.error('Error updating profile:', error);
return new Response('Failed to update profile', { status: 500 });
console.error("Error updating profile:", error);
const msg = "Failed to update profile";
if (isJson) {
return new Response(JSON.stringify({ error: msg }), { status: 500 });
}
return new Response(msg, { status: 500 });
}
};

View File

@@ -294,6 +294,15 @@ const isDraft = invoice.status === 'draft';
<span class="text-base-content/60">Subtotal</span>
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
</div>
{(invoice.discountAmount > 0) && (
<div class="flex justify-between text-sm">
<span class="text-base-content/60">
Discount
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
</span>
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span>
</div>
)}
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
<div class="flex justify-between text-sm items-center group">
<span class="text-base-content/60 flex items-center gap-2">

View File

@@ -38,6 +38,10 @@ if (!membership) {
// Format dates for input[type="date"]
const issueDateStr = invoice.issueDate.toISOString().split('T')[0];
const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
const discountValueDisplay = invoice.discountType === 'fixed'
? (invoice.discountValue || 0) / 100
: (invoice.discountValue || 0);
---
<DashboardLayout title={`Edit ${invoice.number} - Chronus`}>
@@ -112,6 +116,27 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
/>
</div>
<!-- Discount -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Discount</span>
</label>
<div class="join w-full">
<select name="discountType" class="select select-bordered join-item">
<option value="percentage" selected={!invoice.discountType || invoice.discountType === 'percentage'}>%</option>
<option value="fixed" selected={invoice.discountType === 'fixed'}>Fixed</option>
</select>
<input
type="number"
name="discountValue"
step="0.01"
min="0"
class="input input-bordered join-item w-full"
value={discountValueDisplay}
/>
</div>
</div>
<!-- Tax Rate -->
<div class="form-control">
<label class="label">

View File

@@ -52,6 +52,8 @@ const selectedMemberId = url.searchParams.get('member') || '';
const selectedCategoryId = url.searchParams.get('category') || '';
const selectedClientId = url.searchParams.get('client') || '';
const timeRange = url.searchParams.get('range') || 'week';
const customFrom = url.searchParams.get('from');
const customTo = url.searchParams.get('to');
const now = new Date();
let startDate = new Date();
@@ -78,6 +80,16 @@ switch (timeRange) {
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
break;
case 'custom':
if (customFrom) {
const parts = customFrom.split('-');
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
}
if (customTo) {
const parts = customTo.split('-');
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
}
break;
}
const conditions = [
@@ -250,6 +262,7 @@ function getTimeRangeLabel(range: string) {
case 'mtd': return 'Month to Date';
case 'ytd': return 'Year to Date';
case 'last-month': return 'Last Month';
case 'custom': return 'Custom Range';
default: return 'Last 7 Days';
}
}
@@ -273,9 +286,39 @@ function getTimeRangeLabel(range: string) {
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
</select>
</div>
{timeRange === 'custom' && (
<>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">From Date</span>
</label>
<input
type="date"
name="from"
class="input input-bordered w-full"
value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))}
onchange="this.form.submit()"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">To Date</span>
</label>
<input
type="date"
name="to"
class="input input-bordered w-full"
value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))}
onchange="this.form.submit()"
/>
</div>
</>
)}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Team Member</span>
@@ -328,7 +371,7 @@ function getTimeRangeLabel(range: string) {
width: 100%;
}
}
select {
select, input {
width: 100%;
}
</style>
@@ -709,10 +752,18 @@ function getTimeRangeLabel(range: string) {
{/* Detailed Entries */}
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">
<Icon name="heroicons:document-text" class="w-6 h-6" />
Detailed Entries ({entries.length})
</h2>
{entries.length > 0 && (
<a href={`/api/reports/export${url.search}`} class="btn btn-sm btn-outline" target="_blank">
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
Export CSV
</a>
)}
</div>
{entries.length > 0 ? (
<div class="overflow-x-auto">
<table class="table table-zebra">

View File

@@ -2,8 +2,12 @@
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { apiTokens } from '../../db/schema';
import { apiTokens, passkeys } from '../../db/schema';
import { eq, desc } from 'drizzle-orm';
import ProfileForm from '../../components/settings/ProfileForm.vue';
import PasswordForm from '../../components/settings/PasswordForm.vue';
import ApiTokenManager from '../../components/settings/ApiTokenManager.vue';
import PasskeyManager from '../../components/settings/PasskeyManager.vue';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
@@ -16,6 +20,12 @@ const userTokens = await db.select()
.where(eq(apiTokens.userId, user.id))
.orderBy(desc(apiTokens.createdAt))
.all();
const userPasskeys = await db.select()
.from(passkeys)
.where(eq(passkeys.userId, user.id))
.orderBy(desc(passkeys.createdAt))
.all();
---
<DashboardLayout title="Account Settings - Chronus">
@@ -40,177 +50,25 @@ const userTokens = await db.select()
)}
<!-- Profile Information -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon name="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
Profile Information
</h2>
<form action="/api/user/update-profile" method="POST" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
</label>
<input
type="text"
name="name"
value={user.name}
placeholder="Your full name"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Email</span>
</label>
<input
type="email"
name="email"
value={user.email}
placeholder="your@email.com"
class="input input-bordered w-full"
disabled
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<Icon name="heroicons:check" class="w-5 h-5" />
Save Changes
</button>
</div>
</form>
</div>
</div>
<ProfileForm client:load user={user} />
<!-- Change Password -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon name="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
Change Password
</h2>
<PasswordForm client:load />
<form action="/api/user/change-password" method="POST" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
</label>
<input
type="password"
name="currentPassword"
placeholder="Enter current password"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
</label>
<input
type="password"
name="newPassword"
placeholder="Enter new password"
class="input input-bordered w-full"
required
minlength="8"
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
</div>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
</label>
<input
type="password"
name="confirmPassword"
placeholder="Confirm new password"
class="input input-bordered w-full"
required
minlength="8"
/>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<Icon name="heroicons:lock-closed" class="w-5 h-5" />
Update Password
</button>
</div>
</form>
</div>
</div>
<!-- Passkeys -->
<PasskeyManager client:load initialPasskeys={userPasskeys.map(pk => ({
...pk,
lastUsedAt: pk.lastUsedAt ? pk.lastUsedAt.toISOString() : null,
createdAt: pk.createdAt ? pk.createdAt.toISOString() : null
}))} />
<!-- API Tokens -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl">
<Icon name="heroicons:code-bracket-square" class="w-5 h-5 sm:w-6 sm:h-6" />
API Tokens
</h2>
<button class="btn btn-primary btn-sm" onclick="createTokenModal.showModal()">
<Icon name="heroicons:plus" class="w-4 h-4" />
Create Token
</button>
</div>
<ApiTokenManager client:load initialTokens={userTokens.map(t => ({
...t,
lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null,
createdAt: t.createdAt ? t.createdAt.toISOString() : ''
}))} />
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Last Used</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{userTokens.length === 0 ? (
<tr>
<td colspan="4" class="text-center text-base-content/60 py-4">
No API tokens found. Create one to access the API.
</td>
</tr>
) : (
userTokens.map(token => (
<tr>
<td class="font-medium">{token.name}</td>
<td class="text-sm">
{token.lastUsedAt ? token.lastUsedAt.toLocaleDateString() : 'Never'}
</td>
<td class="text-sm">
{token.createdAt ? token.createdAt.toLocaleDateString() : 'N/A'}
</td>
<td>
<button
class="btn btn-ghost btn-xs text-error"
onclick={`deleteToken('${token.id}')`}
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<!-- Account Info -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
@@ -238,132 +96,5 @@ const userTokens = await db.select()
</div>
</div>
<!-- Create Token Modal -->
<dialog id="createTokenModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Create API Token</h3>
<p class="py-4 text-sm text-base-content/70">
API tokens allow you to authenticate with the API programmatically.
Give your token a descriptive name.
</p>
<form id="createTokenForm" method="dialog" class="space-y-4">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Token Name</span>
</label>
<input
type="text"
name="name"
id="tokenName"
placeholder="e.g. CI/CD Pipeline"
class="input input-bordered w-full"
required
/>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="createTokenModal.close()">Cancel</button>
<button type="submit" class="btn btn-primary">Generate Token</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Show Token Modal -->
<dialog id="showTokenModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg text-success flex items-center gap-2">
<Icon name="heroicons:check-circle" class="w-6 h-6" />
Token Created
</h3>
<p class="py-4">
Make sure to copy your personal access token now. You won't be able to see it again!
</p>
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group">
<span id="newTokenDisplay"></span>
<button
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
onclick="copyToken()"
title="Copy to clipboard"
>
<Icon name="heroicons:clipboard" class="w-4 h-4" />
</button>
</div>
<div class="modal-action">
<button class="btn btn-primary" onclick="closeShowTokenModal()">Done</button>
</div>
</div>
</dialog>
<script is:inline>
// Handle Token Creation
const createTokenForm = document.getElementById('createTokenForm');
createTokenForm.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('tokenName').value;
const formData = new FormData();
formData.append('name', name);
try {
const response = await fetch('/api/user/tokens', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
document.getElementById('createTokenModal').close();
document.getElementById('newTokenDisplay').innerText = data.token;
document.getElementById('showTokenModal').showModal();
document.getElementById('tokenName').value = ''; // Reset form
} else {
alert('Failed to create token');
}
} catch (error) {
console.error('Error creating token:', error);
alert('An error occurred');
}
});
// Handle Token Copy
function copyToken() {
const token = document.getElementById('newTokenDisplay').innerText;
navigator.clipboard.writeText(token);
}
// Handle Closing Show Token Modal (refresh page to show new token in list)
function closeShowTokenModal() {
document.getElementById('showTokenModal').close();
window.location.reload();
}
// Handle Token Deletion
async function deleteToken(id) {
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) {
return;
}
try {
const response = await fetch(`/api/user/tokens/${id}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to delete token');
}
} catch (error) {
console.error('Error deleting token:', error);
alert('An error occurred');
}
}
</script>
</DashboardLayout>

View File

@@ -16,6 +16,40 @@ const errorMessage =
---
<Layout title="Login - Chronus">
<script>
import { startAuthentication } from "@simplewebauthn/browser";
const loginBtn = document.getElementById("passkey-login");
loginBtn?.addEventListener("click", async () => {
// 1. Get options from server
const resp = await fetch("/api/auth/passkey/login/start");
const options = await resp.json();
// 2. Browser handles interaction
let asseResp;
try {
asseResp = await startAuthentication(options);
} catch (error) {
console.error(error);
return;
}
// 3. Verify with server
const verificationResp = await fetch("/api/auth/passkey/login/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(asseResp),
});
const verificationJSON = await verificationResp.json();
if (verificationJSON.verified) {
window.location.href = "/dashboard";
} else {
alert("Login failed");
}
});
</script>
<div class="flex justify-center items-center flex-1 bg-base-100">
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
<div class="card-body">
@@ -60,6 +94,11 @@ const errorMessage =
<button class="btn btn-primary w-full mt-6">Sign In</button>
</form>
<button id="passkey-login" class="btn btn-secondary w-full mt-4">
<Icon name="heroicons:finger-print" class="w-5 h-5 mr-2" />
Sign in with Passkey
</button>
<div class="divider">OR</div>
<div class="text-center">

View File

@@ -40,6 +40,9 @@ interface Invoice {
dueDate: Date;
currency: string;
subtotal: number;
discountValue: number | null;
discountType: string | null;
discountAmount: number | null;
taxRate: number | null;
taxAmount: number;
total: number;
@@ -503,6 +506,24 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
formatCurrency(invoice.subtotal),
),
]),
(invoice.discountAmount ?? 0) > 0
? h(View, { style: styles.totalRow }, [
h(
Text,
{ style: styles.totalLabel },
`Discount${
invoice.discountType === "percentage"
? ` (${invoice.discountValue}%)`
: ""
}`,
),
h(
Text,
{ style: styles.totalValue },
`-${formatCurrency(invoice.discountAmount ?? 0)}`,
),
])
: null,
(invoice.taxRate ?? 0) > 0
? h(View, { style: styles.totalRow }, [
h(

View File

@@ -27,16 +27,34 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
// Note: amounts are in cents
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
const taxRate = invoice.taxRate || 0;
const taxAmount = Math.round(subtotal * (taxRate / 100));
// Calculate discount
const discountType = invoice.discountType || "percentage";
const discountValue = invoice.discountValue || 0;
let discountAmount = 0;
const total = subtotal + taxAmount;
if (discountType === "percentage") {
discountAmount = Math.round(subtotal * (discountValue / 100));
} else {
// Fixed amount is assumed to be in cents
discountAmount = Math.round(discountValue);
}
// Ensure discount doesn't exceed subtotal
discountAmount = Math.max(0, Math.min(discountAmount, subtotal));
const taxableAmount = subtotal - discountAmount;
const taxRate = invoice.taxRate || 0;
const taxAmount = Math.round(taxableAmount * (taxRate / 100));
const total = taxableAmount + taxAmount;
// Update invoice
await db
.update(invoices)
.set({
subtotal,
discountAmount,
taxAmount,
total,
})