8 Commits
2.0.0 ... 2.2.1

Author SHA1 Message Date
df82a02f41 2.2.1 - Misc improvements and cleanup
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
2026-01-19 21:08:46 -07:00
8a3932a013 Optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m5s
2026-01-19 20:55:47 -07:00
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
47 changed files with 5758 additions and 690 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.1",
"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": {

678
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 ClientData {
name: string;
@@ -29,57 +37,61 @@ const props = defineProps<{
}>();
const chartData = computed(() => ({
labels: props.clients.map(c => c.name),
datasets: [{
label: 'Time Tracked',
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: '#6366f1',
borderColor: '#4f46e5',
labels: props.clients.map((c) => c.name),
datasets: [
{
label: "Time Tracked",
data: props.clients.map((c) => c.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: "#6366f1",
borderColor: "#4f46e5",
borderWidth: 1,
}]
},
],
}));
const chartOptions = {
const chartOptions: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
color: '#e2e8f0',
callback: function(value: number) {
const hours = Math.floor(value / 60);
const mins = value % 60;
color: "#e2e8f0",
callback: function (value: string | number) {
const numValue =
typeof value === "string" ? parseFloat(value) : value;
const hours = Math.floor(numValue / 60);
const mins = Math.round(numValue % 60);
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
},
},
grid: {
color: '#334155'
}
color: "#334155",
},
},
x: {
ticks: {
color: '#e2e8f0'
color: "#e2e8f0",
},
grid: {
display: false
}
}
display: false,
},
},
},
plugins: {
legend: {
display: false
display: false,
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw);
label: function (context) {
const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
}
}
}
}
},
},
},
},
};
</script>

View File

@@ -23,7 +23,6 @@ const isSubmitting = ref(false);
const error = ref("");
const success = ref(false);
// Set default dates to today
const today = new Date().toISOString().split("T")[0];
startDate.value = today;
endDate.value = today;
@@ -114,12 +113,10 @@ async function submitManualEntry() {
if (res.ok) {
success.value = true;
// Calculate duration for success message
const start = new Date(startDateTime);
const end = new Date(endDateTime);
const duration = formatDuration(start, end);
// Reset form
description.value = "";
selectedClientId.value = "";
selectedCategoryId.value = "";
@@ -129,7 +126,6 @@ async function submitManualEntry() {
startTime.value = "";
endTime.value = "";
// Emit event and reload after a short delay
setTimeout(() => {
emit("entryCreated");
window.location.reload();

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,58 +37,62 @@ 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) {
const hours = Math.floor(value / 60);
const mins = value % 60;
color: "#e2e8f0",
callback: function (value: string | number) {
const numValue =
typeof value === "string" ? parseFloat(value) : value;
const hours = Math.floor(numValue / 60);
const mins = Math.round(numValue % 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: {
label: function(context: any) {
const minutes = Math.round(context.raw);
label: function (context) {
const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
}
}
}
}
},
},
},
},
};
</script>

View File

@@ -31,7 +31,6 @@ function formatTime(ms: number) {
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
// Calculate rounded version
const totalMinutes = Math.round(ms / 1000 / 60);
const roundedHours = Math.floor(totalMinutes / 60);
const roundedMinutes = totalMinutes % 60;

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from "vue";
import { Icon } from "@iconify/vue";
import { startAuthentication } from "@simplewebauthn/browser";
const loading = ref(false);
const error = ref<string | null>(null);
async function handlePasskeyLogin() {
loading.value = true;
error.value = null;
try {
const resp = await fetch("/api/auth/passkey/login/start");
if (!resp.ok) {
throw new Error("Failed to start passkey login");
}
const options = await resp.json();
let asseResp;
try {
asseResp = await startAuthentication({ optionsJSON: options });
} catch (err) {
if ((err as any).name === "NotAllowedError") {
return;
}
console.error(err);
error.value = "Failed to authenticate with passkey";
return;
}
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 {
error.value = "Login failed. Please try again.";
}
} catch (err) {
console.error("Error during passkey login:", err);
error.value = "An error occurred during login";
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<button
class="btn btn-secondary w-full"
@click="handlePasskeyLogin"
:disabled="loading"
>
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<Icon v-else icon="heroicons:finger-print" class="w-5 h-5 mr-2" />
Sign in with Passkey
</button>
<div v-if="error" role="alert" class="alert alert-error mt-4">
<Icon icon="heroicons:exclamation-circle" class="w-6 h-6" />
<span>{{ error }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,256 @@
<script setup lang="ts">
import { ref, onMounted } 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);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
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;
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">
<span v-if="isMounted">{{
formatDate(token.lastUsedAt)
}}</span>
<span v-else>{{ token.lastUsedAt || "Never" }}</span>
</td>
<td class="text-sm">
<span v-if="isMounted">{{
formatDate(token.createdAt)
}}</span>
<span v-else>{{ token.createdAt }}</span>
</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,169 @@
<script setup lang="ts">
import { ref, onMounted } 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);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
function formatDate(dateString: string | null) {
if (!dateString) return "N/A";
return new Date(dateString).toLocaleDateString();
}
async function registerPasskey() {
loading.value = true;
try {
const resp = await fetch("/api/auth/passkey/register/start");
if (!resp.ok) {
throw new Error("Failed to start registration");
}
const options = await resp.json();
let attResp;
try {
attResp = await startRegistration({ optionsJSON: options });
} catch (error) {
if ((error as any).name === "NotAllowedError") {
return;
}
console.error(error);
alert("Failed to register passkey: " + (error as any).message);
return;
}
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) {
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) {
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">
<span v-if="isMounted">
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : "Never" }}
</span>
<span v-else>{{ pk.lastUsedAt || "Never" }}</span>
</td>
<td class="text-sm">
<span v-if="isMounted">{{ formatDate(pk.createdAt) }}</span>
<span v-else>{{ pk.createdAt || "N/A" }}</span>
</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,130 @@
<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!' };
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
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,135 @@
<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!",
};
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

@@ -4,7 +4,6 @@ import * as schema from "./schema";
import path from "path";
import fs from "fs";
// Define the database type based on the schema
type Database = ReturnType<typeof drizzle<typeof schema>>;
let _db: Database | null = null;

View File

@@ -270,16 +270,19 @@ export const invoices = sqliteTable(
organizationId: text("organization_id").notNull(),
clientId: text("client_id").notNull(),
number: text("number").notNull(),
type: text("type").notNull().default("invoice"), // 'invoice' or 'quote'
status: text("status").notNull().default("draft"), // 'draft', 'sent', 'paid', 'void', 'accepted', 'declined'
type: text("type").notNull().default("invoice"),
status: text("status").notNull().default("draft"),
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
notes: text("notes"),
currency: text("currency").default("USD").notNull(),
subtotal: integer("subtotal").notNull().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
subtotal: integer("subtotal").notNull().default(0),
discountValue: real("discount_value").default(0),
discountType: text("discount_type").default("percentage"),
discountAmount: integer("discount_amount").default(0),
taxRate: real("tax_rate").default(0),
taxAmount: integer("tax_amount").notNull().default(0),
total: integer("total").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
@@ -309,8 +312,8 @@ export const invoiceItems = sqliteTable(
invoiceId: text("invoice_id").notNull(),
description: text("description").notNull(),
quantity: real("quantity").notNull().default(1),
unitPrice: integer("unit_price").notNull().default(0), // in cents
amount: integer("amount").notNull().default(0), // in cents
unitPrice: integer("unit_price").notNull().default(0),
amount: integer("amount").notNull().default(0),
},
(table: any) => ({
invoiceFk: foreignKey({
@@ -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(),
userId: text("user_id").notNull(),
publicKey: text("public_key").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"),
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

@@ -18,7 +18,6 @@ if (!user) {
return Astro.redirect('/login');
}
// Get user's team memberships
const userMemberships = await db.select({
membership: members,
organization: organizations,
@@ -28,7 +27,6 @@ const userMemberships = await db.select({
.where(eq(members.userId, user.id))
.all();
// Get current team from cookie or use first membership
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
---

View File

@@ -24,7 +24,6 @@ export async function validateApiToken(token: string) {
return null;
}
// Update last used at
await db
.update(apiTokens)
.set({ lastUsedAt: new Date() })

View File

@@ -4,7 +4,6 @@
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
*/
export function formatDuration(ms: number): string {
// Calculate rounded version for easy reading
const totalMinutes = Math.round(ms / 1000 / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;

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,44 @@
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,
});
}
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

@@ -14,7 +14,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence
const invoice = await db
.select()
.from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
});
}
// Verify membership
const membership = await db
.select()
.from(members)
@@ -48,7 +46,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
}
try {
// Generate next invoice number
const lastInvoice = await db
.select()
.from(invoices)
@@ -74,11 +71,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
}
}
// Convert quote to invoice:
// 1. Change type to 'invoice'
// 2. Set status to 'draft' (so user can review before sending)
// 3. Update number to next invoice sequence
// 4. Update issue date to today
await db
.update(invoices)
.set({

View File

@@ -20,7 +20,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
return new Response("Unauthorized", { status: 401 });
}
// Fetch invoice with related data
const invoiceResult = await db
.select({
invoice: invoices,
@@ -39,7 +38,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
const { invoice, client, organization } = invoiceResult;
// Verify access
const membership = await db
.select()
.from(members)
@@ -55,7 +53,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
return new Response("Forbidden", { status: 403 });
}
// Fetch items
const items = await db
.select()
.from(invoiceItems)
@@ -66,8 +63,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
return new Response("Client not found", { status: 404 });
}
// Generate PDF using Vue PDF
// Suppress verbose logging from PDF renderer
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
console.log = () => {};
@@ -83,7 +78,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
const stream = await renderToStream(pdfDocument);
// Restore console.log
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;

View File

@@ -64,7 +64,6 @@ export const POST: APIRoute = async ({
const quantity = parseFloat(quantityStr);
const unitPriceMajor = parseFloat(unitPriceStr);
// Convert to cents
const unitPrice = Math.round(unitPriceMajor * 100);
const amount = Math.round(quantity * unitPrice);
@@ -77,7 +76,6 @@ export const POST: APIRoute = async ({
amount,
});
// Update invoice totals
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);

View File

@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence and check status
const invoice = await db
.select()
.from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)
@@ -47,7 +45,6 @@ export const POST: APIRoute = async ({
return new Response("Unauthorized", { status: 401 });
}
// Only allow editing if draft
if (invoice.status !== "draft") {
return new Response("Cannot edit a finalized invoice", { status: 400 });
}
@@ -59,7 +56,6 @@ export const POST: APIRoute = async ({
return new Response("Item ID required", { status: 400 });
}
// Verify item belongs to invoice
const item = await db
.select()
.from(invoiceItems)
@@ -73,7 +69,6 @@ export const POST: APIRoute = async ({
try {
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
// Update invoice totals
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);

View File

@@ -35,7 +35,6 @@ export const POST: APIRoute = async ({
return new Response("Invalid status", { status: 400 });
}
// Fetch invoice to verify existence and check ownership
const invoice = await db
.select()
.from(invoices)
@@ -46,7 +45,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)

View File

@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence
const invoice = await db
.select()
.from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)

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 && 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>
@@ -511,7 +554,7 @@ function getTimeRangeLabel(range: string) {
</h2>
<div class="h-64 w-full">
<CategoryChart
client:load
client:visible
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
name: s.category.name,
totalTime: s.totalTime,
@@ -533,7 +576,7 @@ function getTimeRangeLabel(range: string) {
</h2>
<div class="h-64 w-full">
<ClientChart
client:load
client:visible
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
name: s.client.name,
totalTime: s.totalTime
@@ -555,7 +598,7 @@ function getTimeRangeLabel(range: string) {
</h2>
<div class="h-64 w-full">
<MemberChart
client:load
client:visible
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
name: s.member.name,
totalTime: s.totalTime
@@ -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:idle 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:idle 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

@@ -207,7 +207,7 @@ const paginationPages = getPaginationPages(page, totalPages);
</div>
) : (
<ManualEntry
client:load
client:idle
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}

View File

@@ -1,6 +1,7 @@
---
import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components';
import PasskeyLogin from '../components/auth/PasskeyLogin.vue';
if (Astro.locals.user) {
return Astro.redirect('/dashboard');
@@ -60,6 +61,8 @@ const errorMessage =
<button class="btn btn-primary w-full mt-6">Sign In</button>
</form>
<PasskeyLogin client:idle />
<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

@@ -24,19 +24,36 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
.all();
// Calculate totals
// 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,
})