2 Commits
2.2.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
29 changed files with 329 additions and 239 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "chronus", "name": "chronus",
"type": "module", "type": "module",
"version": "2.2.0", "version": "2.2.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",

31
pnpm-lock.yaml generated
View File

@@ -1711,8 +1711,8 @@ packages:
supports-color: supports-color:
optional: true optional: true
decode-named-character-reference@1.2.0: decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
decompress-response@6.0.0: decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
@@ -2366,6 +2366,7 @@ packages:
libsql@0.5.22: libsql@0.5.22:
resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==} resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==}
cpu: [x64, arm64, wasm32, arm]
os: [darwin, linux, win32] os: [darwin, linux, win32]
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
@@ -2672,8 +2673,8 @@ packages:
nlcst-to-string@4.0.0: nlcst-to-string@4.0.0:
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
node-abi@3.86.0: node-abi@3.87.0:
resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==} resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
node-domexception@1.0.0: node-domexception@1.0.0:
@@ -3157,8 +3158,8 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
tar@7.5.3: tar@7.5.4:
resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==} resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==}
engines: {node: '>=18'} engines: {node: '>=18'}
tiny-inflate@1.0.3: tiny-inflate@1.0.3:
@@ -4246,7 +4247,7 @@ snapshots:
local-pkg: 1.1.2 local-pkg: 1.1.2
pathe: 2.0.3 pathe: 2.0.3
svgo: 3.3.2 svgo: 3.3.2
tar: 7.5.3 tar: 7.5.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5522,7 +5523,7 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decode-named-character-reference@1.2.0: decode-named-character-reference@1.3.0:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
@@ -6268,7 +6269,7 @@ snapshots:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
'@types/unist': 3.0.3 '@types/unist': 3.0.3
decode-named-character-reference: 1.2.0 decode-named-character-reference: 1.3.0
devlop: 1.1.0 devlop: 1.1.0
mdast-util-to-string: 4.0.0 mdast-util-to-string: 4.0.0
micromark: 4.0.2 micromark: 4.0.2
@@ -6381,7 +6382,7 @@ snapshots:
micromark-core-commonmark@2.0.3: micromark-core-commonmark@2.0.3:
dependencies: dependencies:
decode-named-character-reference: 1.2.0 decode-named-character-reference: 1.3.0
devlop: 1.1.0 devlop: 1.1.0
micromark-factory-destination: 2.0.1 micromark-factory-destination: 2.0.1
micromark-factory-label: 2.0.1 micromark-factory-label: 2.0.1
@@ -6514,7 +6515,7 @@ snapshots:
micromark-util-decode-string@2.0.1: micromark-util-decode-string@2.0.1:
dependencies: dependencies:
decode-named-character-reference: 1.2.0 decode-named-character-reference: 1.3.0
micromark-util-character: 2.1.1 micromark-util-character: 2.1.1
micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-numeric-character-reference: 2.0.2
micromark-util-symbol: 2.0.1 micromark-util-symbol: 2.0.1
@@ -6552,7 +6553,7 @@ snapshots:
dependencies: dependencies:
'@types/debug': 4.1.12 '@types/debug': 4.1.12
debug: 4.4.3 debug: 4.4.3
decode-named-character-reference: 1.2.0 decode-named-character-reference: 1.3.0
devlop: 1.1.0 devlop: 1.1.0
micromark-core-commonmark: 2.0.3 micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1 micromark-factory-space: 2.0.1
@@ -6623,7 +6624,7 @@ snapshots:
dependencies: dependencies:
'@types/nlcst': 2.0.3 '@types/nlcst': 2.0.3
node-abi@3.86.0: node-abi@3.87.0:
dependencies: dependencies:
semver: 7.7.3 semver: 7.7.3
optional: true optional: true
@@ -6809,7 +6810,7 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
mkdirp-classic: 0.5.3 mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0 napi-build-utils: 2.0.0
node-abi: 3.86.0 node-abi: 3.87.0
pump: 3.0.3 pump: 3.0.3
rc: 1.2.8 rc: 1.2.8
simple-get: 4.0.1 simple-get: 4.0.1
@@ -7240,7 +7241,7 @@ snapshots:
readable-stream: 3.6.2 readable-stream: 3.6.2
optional: true optional: true
tar@7.5.3: tar@7.5.4:
dependencies: dependencies:
'@isaacs/fs-minipass': 4.0.1 '@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0 chownr: 3.0.0

View File

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

View File

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

View File

@@ -58,9 +58,11 @@ const chartOptions: ChartOptions<"bar"> = {
beginAtZero: true, beginAtZero: true,
ticks: { ticks: {
color: "#e2e8f0", color: "#e2e8f0",
callback: function (value: any) { callback: function (value: string | number) {
const hours = Math.floor(value / 60); const numValue =
const mins = value % 60; 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`; return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}, },
}, },
@@ -83,8 +85,8 @@ const chartOptions: ChartOptions<"bar"> = {
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: function (context: any) { label: function (context) {
const minutes = Math.round(context.raw); const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const mins = minutes % 60; const mins = minutes % 60;
return ` ${hours}h ${mins}m`; return ` ${hours}h ${mins}m`;

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")}`; 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 totalMinutes = Math.round(ms / 1000 / 60);
const roundedHours = Math.floor(totalMinutes / 60); const roundedHours = Math.floor(totalMinutes / 60);
const roundedMinutes = 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

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref, onMounted } from "vue";
import { Icon } from '@iconify/vue'; import { Icon } from "@iconify/vue";
interface ApiToken { interface ApiToken {
id: string; id: string;
@@ -16,12 +16,17 @@ const props = defineProps<{
const tokens = ref<ApiToken[]>(props.initialTokens); const tokens = ref<ApiToken[]>(props.initialTokens);
const createModalOpen = ref(false); const createModalOpen = ref(false);
const showTokenModalOpen = ref(false); const showTokenModalOpen = ref(false);
const newTokenName = ref(''); const newTokenName = ref("");
const newTokenValue = ref(''); const newTokenValue = ref("");
const loading = ref(false); const loading = ref(false);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
function formatDate(dateString: string | null) { function formatDate(dateString: string | null) {
if (!dateString) return 'Never'; if (!dateString) return "Never";
return new Date(dateString).toLocaleDateString(); return new Date(dateString).toLocaleDateString();
} }
@@ -30,10 +35,10 @@ async function createToken() {
loading.value = true; loading.value = true;
try { try {
const response = await fetch('/api/user/tokens', { const response = await fetch("/api/user/tokens", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ name: newTokenName.value }), body: JSON.stringify({ name: newTokenName.value }),
}); });
@@ -43,47 +48,50 @@ async function createToken() {
const { token, ...tokenMeta } = data; const { token, ...tokenMeta } = data;
// Add to beginning of list
tokens.value.unshift({ tokens.value.unshift({
id: tokenMeta.id, id: tokenMeta.id,
name: tokenMeta.name, name: tokenMeta.name,
lastUsedAt: tokenMeta.lastUsedAt, lastUsedAt: tokenMeta.lastUsedAt,
createdAt: tokenMeta.createdAt createdAt: tokenMeta.createdAt,
}); });
newTokenValue.value = token; newTokenValue.value = token;
createModalOpen.value = false; createModalOpen.value = false;
showTokenModalOpen.value = true; showTokenModalOpen.value = true;
newTokenName.value = ''; newTokenName.value = "";
} else { } else {
alert('Failed to create token'); alert("Failed to create token");
} }
} catch (error) { } catch (error) {
console.error('Error creating token:', error); console.error("Error creating token:", error);
alert('An error occurred'); alert("An error occurred");
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
async function deleteToken(id: string) { async function deleteToken(id: string) {
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) { if (
!confirm(
"Are you sure you want to revoke this token? Any applications using it will stop working.",
)
) {
return; return;
} }
try { try {
const response = await fetch(`/api/user/tokens/${id}`, { const response = await fetch(`/api/user/tokens/${id}`, {
method: 'DELETE' method: "DELETE",
}); });
if (response.ok) { if (response.ok) {
tokens.value = tokens.value.filter(t => t.id !== id); tokens.value = tokens.value.filter((t) => t.id !== id);
} else { } else {
alert('Failed to delete token'); alert("Failed to delete token");
} }
} catch (error) { } catch (error) {
console.error('Error deleting token:', error); console.error("Error deleting token:", error);
alert('An error occurred'); alert("An error occurred");
} }
} }
@@ -93,7 +101,7 @@ function copyToken() {
function closeShowTokenModal() { function closeShowTokenModal() {
showTokenModalOpen.value = false; showTokenModalOpen.value = false;
newTokenValue.value = ''; newTokenValue.value = "";
} }
</script> </script>
@@ -103,10 +111,16 @@ function closeShowTokenModal() {
<div class="card-body p-4 sm:p-6"> <div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl"> <h2 class="card-title text-lg sm:text-xl">
<Icon icon="heroicons:code-bracket-square" class="w-5 h-5 sm:w-6 sm:h-6" /> <Icon
icon="heroicons:code-bracket-square"
class="w-5 h-5 sm:w-6 sm:h-6"
/>
API Tokens API Tokens
</h2> </h2>
<button class="btn btn-primary btn-sm" @click="createModalOpen = true"> <button
class="btn btn-primary btn-sm"
@click="createModalOpen = true"
>
<Icon icon="heroicons:plus" class="w-4 h-4" /> <Icon icon="heroicons:plus" class="w-4 h-4" />
Create Token Create Token
</button> </button>
@@ -131,10 +145,16 @@ function closeShowTokenModal() {
<tr v-else v-for="token in tokens" :key="token.id"> <tr v-else v-for="token in tokens" :key="token.id">
<td class="font-medium">{{ token.name }}</td> <td class="font-medium">{{ token.name }}</td>
<td class="text-sm"> <td class="text-sm">
{{ formatDate(token.lastUsedAt) }} <span v-if="isMounted">{{
formatDate(token.lastUsedAt)
}}</span>
<span v-else>{{ token.lastUsedAt || "Never" }}</span>
</td> </td>
<td class="text-sm"> <td class="text-sm">
{{ formatDate(token.createdAt) }} <span v-if="isMounted">{{
formatDate(token.createdAt)
}}</span>
<span v-else>{{ token.createdAt }}</span>
</td> </td>
<td> <td>
<button <button
@@ -175,15 +195,24 @@ function closeShowTokenModal() {
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" @click="createModalOpen = false">Cancel</button> <button type="button" class="btn" @click="createModalOpen = false">
Cancel
</button>
<button type="submit" class="btn btn-primary" :disabled="loading"> <button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-sm"></span> <span
v-if="loading"
class="loading loading-spinner loading-sm"
></span>
Generate Token Generate Token
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<form method="dialog" class="modal-backdrop" @click="createModalOpen = false"> <form
method="dialog"
class="modal-backdrop"
@click="createModalOpen = false"
>
<button>close</button> <button>close</button>
</form> </form>
</dialog> </dialog>
@@ -196,10 +225,13 @@ function closeShowTokenModal() {
Token Created Token Created
</h3> </h3>
<p class="py-4"> <p class="py-4">
Make sure to copy your personal access token now. You won't be able to see it again! Make sure to copy your personal access token now. You won't be able to
see it again!
</p> </p>
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group"> <div
class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group"
>
<span>{{ newTokenValue }}</span> <span>{{ newTokenValue }}</span>
<button <button
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity" class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
@@ -211,11 +243,13 @@ function closeShowTokenModal() {
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button class="btn btn-primary" @click="closeShowTokenModal">Done</button> <button class="btn btn-primary" @click="closeShowTokenModal">
Done
</button>
</div> </div>
</div> </div>
<form method="dialog" class="modal-backdrop" @click="closeShowTokenModal"> <form method="dialog" class="modal-backdrop" @click="closeShowTokenModal">
<button>close</button> <button>close</button>
</form> </form>
</dialog> </dialog>
</div> </div>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref, onMounted } from "vue";
import { Icon } from '@iconify/vue'; import { Icon } from "@iconify/vue";
import { startRegistration } from '@simplewebauthn/browser'; import { startRegistration } from "@simplewebauthn/browser";
interface Passkey { interface Passkey {
id: string; id: string;
@@ -17,83 +17,78 @@ const props = defineProps<{
const passkeys = ref<Passkey[]>(props.initialPasskeys); const passkeys = ref<Passkey[]>(props.initialPasskeys);
const loading = ref(false); const loading = ref(false);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
function formatDate(dateString: string | null) { function formatDate(dateString: string | null) {
if (!dateString) return 'N/A'; if (!dateString) return "N/A";
return new Date(dateString).toLocaleDateString(); return new Date(dateString).toLocaleDateString();
} }
async function registerPasskey() { async function registerPasskey() {
loading.value = true; loading.value = true;
try { try {
// 1. Get options from server
const resp = await fetch("/api/auth/passkey/register/start"); const resp = await fetch("/api/auth/passkey/register/start");
if (!resp.ok) { if (!resp.ok) {
throw new Error("Failed to start registration"); throw new Error("Failed to start registration");
} }
const options = await resp.json(); const options = await resp.json();
// 2. Browser handles interaction
let attResp; let attResp;
try { try {
attResp = await startRegistration(options); attResp = await startRegistration({ optionsJSON: options });
} catch (error) { } catch (error) {
if ((error as any).name === 'NotAllowedError') { if ((error as any).name === "NotAllowedError") {
// User cancelled or timed out return;
return;
} }
console.error(error); console.error(error);
alert('Failed to register passkey: ' + (error as any).message); alert("Failed to register passkey: " + (error as any).message);
return; return;
} }
// 3. Verify with server const verificationResp = await fetch("/api/auth/passkey/register/finish", {
const verificationResp = await fetch( method: "POST",
"/api/auth/passkey/register/finish", headers: { "Content-Type": "application/json" },
{ body: JSON.stringify(attResp),
method: "POST", });
headers: { "Content-Type": "application/json" },
body: JSON.stringify(attResp),
}
);
const verificationJSON = await verificationResp.json(); const verificationJSON = await verificationResp.json();
if (verificationJSON.verified) { 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(); window.location.reload();
} else { } else {
alert("Passkey registration failed"); alert("Passkey registration failed");
} }
} catch (error) { } catch (error) {
console.error('Error registering passkey:', error); console.error("Error registering passkey:", error);
alert('An error occurred'); alert("An error occurred");
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
async function deletePasskey(id: string) { async function deletePasskey(id: string) {
if (!confirm('Are you sure you want to remove this passkey?')) { if (!confirm("Are you sure you want to remove this passkey?")) {
return; return;
} }
try { try {
const response = await fetch(`/api/auth/passkey/delete?id=${id}`, { const response = await fetch(`/api/auth/passkey/delete?id=${id}`, {
method: 'DELETE' method: "DELETE",
}); });
if (response.ok) { if (response.ok) {
// Optimistically remove from list passkeys.value = passkeys.value.filter((pk) => pk.id !== id);
passkeys.value = passkeys.value.filter(pk => pk.id !== id);
} else { } else {
alert('Failed to delete passkey'); alert("Failed to delete passkey");
} }
} catch (error) { } catch (error) {
console.error('Error deleting passkey:', error); console.error("Error deleting passkey:", error);
alert('An error occurred'); alert("An error occurred");
} }
} }
</script> </script>
@@ -106,8 +101,15 @@ async function deletePasskey(id: string) {
<Icon icon="heroicons:finger-print" class="w-5 h-5 sm:w-6 sm:h-6" /> <Icon icon="heroicons:finger-print" class="w-5 h-5 sm:w-6 sm:h-6" />
Passkeys Passkeys
</h2> </h2>
<button class="btn btn-primary btn-sm" @click="registerPasskey" :disabled="loading"> <button
<span v-if="loading" class="loading loading-spinner loading-xs"></span> 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" /> <Icon v-else icon="heroicons:plus" class="w-4 h-4" />
Add Passkey Add Passkey
</button> </button>
@@ -131,14 +133,24 @@ async function deletePasskey(id: string) {
</tr> </tr>
<tr v-else v-for="pk in passkeys" :key="pk.id"> <tr v-else v-for="pk in passkeys" :key="pk.id">
<td class="font-medium"> <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> 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>
<td class="text-sm"> <td class="text-sm">
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : 'Never' }} <span v-if="isMounted">
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : "Never" }}
</span>
<span v-else>{{ pk.lastUsedAt || "Never" }}</span>
</td> </td>
<td class="text-sm"> <td class="text-sm">
{{ formatDate(pk.createdAt) }} <span v-if="isMounted">{{ formatDate(pk.createdAt) }}</span>
<span v-else>{{ pk.createdAt || "N/A" }}</span>
</td> </td>
<td> <td>
<button <button

View File

@@ -37,12 +37,10 @@ async function changePassword() {
if (response.ok) { if (response.ok) {
message.value = { type: 'success', text: 'Password changed successfully!' }; message.value = { type: 'success', text: 'Password changed successfully!' };
// Reset form
currentPassword.value = ''; currentPassword.value = '';
newPassword.value = ''; newPassword.value = '';
confirmPassword.value = ''; confirmPassword.value = '';
// Hide success message after 3 seconds
setTimeout(() => { setTimeout(() => {
message.value = null; message.value = null;
}, 3000); }, 3000);

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from "vue";
import { Icon } from '@iconify/vue'; import { Icon } from "@iconify/vue";
const props = defineProps<{ const props = defineProps<{
user: { user: {
@@ -12,33 +12,38 @@ const props = defineProps<{
const name = ref(props.user.name); const name = ref(props.user.name);
const loading = ref(false); const loading = ref(false);
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null); const message = ref<{ type: "success" | "error"; text: string } | null>(null);
async function updateProfile() { async function updateProfile() {
loading.value = true; loading.value = true;
message.value = null; message.value = null;
try { try {
const response = await fetch('/api/user/update-profile', { const response = await fetch("/api/user/update-profile", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ name: name.value }), body: JSON.stringify({ name: name.value }),
}); });
if (response.ok) { if (response.ok) {
message.value = { type: 'success', text: 'Profile updated successfully!' }; message.value = {
// Hide success message after 3 seconds type: "success",
text: "Profile updated successfully!",
};
setTimeout(() => { setTimeout(() => {
message.value = null; message.value = null;
}, 3000); }, 3000);
} else { } else {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
message.value = { type: 'error', text: data.error || 'Failed to update profile' }; message.value = {
type: "error",
text: data.error || "Failed to update profile",
};
} }
} catch (error) { } catch (error) {
message.value = { type: 'error', text: 'An error occurred' }; message.value = { type: "error", text: "An error occurred" };
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -48,8 +53,21 @@ async function updateProfile() {
<template> <template>
<div> <div>
<!-- Success/Error Message Display --> <!-- Success/Error Message Display -->
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']"> <div
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" /> 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> <span>{{ message.text }}</span>
</div> </div>
@@ -63,7 +81,9 @@ async function updateProfile() {
<form @submit.prevent="updateProfile" class="space-y-5"> <form @submit.prevent="updateProfile" class="space-y-5">
<div class="form-control"> <div class="form-control">
<label class="label pb-2"> <label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Full Name</span> <span class="label-text font-medium text-sm sm:text-base"
>Full Name</span
>
</label> </label>
<input <input
type="text" type="text"
@@ -76,7 +96,9 @@ async function updateProfile() {
<div class="form-control"> <div class="form-control">
<label class="label pb-2"> <label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Email</span> <span class="label-text font-medium text-sm sm:text-base"
>Email</span
>
</label> </label>
<input <input
type="email" type="email"
@@ -85,13 +107,23 @@ async function updateProfile() {
disabled disabled
/> />
<div class="label pt-2"> <div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span> <span
class="label-text-alt text-base-content/60 text-xs sm:text-sm"
>Email cannot be changed</span
>
</div> </div>
</div> </div>
<div class="flex justify-end pt-4"> <div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading"> <button
<span v-if="loading" class="loading loading-spinner loading-sm"></span> 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" /> <Icon v-else icon="heroicons:check" class="w-5 h-5" />
Save Changes Save Changes
</button> </button>

View File

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

View File

@@ -270,19 +270,19 @@ export const invoices = sqliteTable(
organizationId: text("organization_id").notNull(), organizationId: text("organization_id").notNull(),
clientId: text("client_id").notNull(), clientId: text("client_id").notNull(),
number: text("number").notNull(), number: text("number").notNull(),
type: text("type").notNull().default("invoice"), // 'invoice' or 'quote' type: text("type").notNull().default("invoice"),
status: text("status").notNull().default("draft"), // 'draft', 'sent', 'paid', 'void', 'accepted', 'declined' status: text("status").notNull().default("draft"),
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(), issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
dueDate: integer("due_date", { mode: "timestamp" }).notNull(), dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
notes: text("notes"), notes: text("notes"),
currency: text("currency").default("USD").notNull(), currency: text("currency").default("USD").notNull(),
subtotal: integer("subtotal").notNull().default(0), // in cents subtotal: integer("subtotal").notNull().default(0),
discountValue: real("discount_value").default(0), discountValue: real("discount_value").default(0),
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed' discountType: text("discount_type").default("percentage"),
discountAmount: integer("discount_amount").default(0), // in cents discountAmount: integer("discount_amount").default(0),
taxRate: real("tax_rate").default(0), // percentage taxRate: real("tax_rate").default(0),
taxAmount: integer("tax_amount").notNull().default(0), // in cents taxAmount: integer("tax_amount").notNull().default(0),
total: integer("total").notNull().default(0), // in cents total: integer("total").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(), () => new Date(),
), ),
@@ -312,8 +312,8 @@ export const invoiceItems = sqliteTable(
invoiceId: text("invoice_id").notNull(), invoiceId: text("invoice_id").notNull(),
description: text("description").notNull(), description: text("description").notNull(),
quantity: real("quantity").notNull().default(1), quantity: real("quantity").notNull().default(1),
unitPrice: integer("unit_price").notNull().default(0), // in cents unitPrice: integer("unit_price").notNull().default(0),
amount: integer("amount").notNull().default(0), // in cents amount: integer("amount").notNull().default(0),
}, },
(table: any) => ({ (table: any) => ({
invoiceFk: foreignKey({ invoiceFk: foreignKey({
@@ -327,13 +327,13 @@ export const invoiceItems = sqliteTable(
export const passkeys = sqliteTable( export const passkeys = sqliteTable(
"passkeys", "passkeys",
{ {
id: text("id").primaryKey(), // The Credential ID id: text("id").primaryKey(),
userId: text("user_id").notNull(), userId: text("user_id").notNull(),
publicKey: text("public_key").notNull(), // Base64 encoded public key publicKey: text("public_key").notNull(),
counter: integer("counter").notNull(), counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(), // 'singleDevice' or 'multiDevice' deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(), backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"), // JSON stringified array transports: text("transports"),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }), lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(), () => new Date(),

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ export const GET: APIRoute = async ({ request, locals }) => {
}); });
} }
// Get user's existing passkeys to prevent registering the same authenticator twice
const userPasskeys = await db.query.passkeys.findMany({ const userPasskeys = await db.query.passkeys.findMany({
where: eq(passkeys.userId, user.id), where: eq(passkeys.userId, user.id),
}); });

View File

@@ -14,7 +14,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
return new Response("Invoice ID required", { status: 400 }); return new Response("Invoice ID required", { status: 400 });
} }
// Fetch invoice to verify existence
const invoice = await db const invoice = await db
.select() .select()
.from(invoices) .from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
}); });
} }
// Verify membership
const membership = await db const membership = await db
.select() .select()
.from(members) .from(members)
@@ -48,7 +46,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
} }
try { try {
// Generate next invoice number
const lastInvoice = await db const lastInvoice = await db
.select() .select()
.from(invoices) .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 await db
.update(invoices) .update(invoices)
.set({ .set({

View File

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

View File

@@ -64,7 +64,6 @@ export const POST: APIRoute = async ({
const quantity = parseFloat(quantityStr); const quantity = parseFloat(quantityStr);
const unitPriceMajor = parseFloat(unitPriceStr); const unitPriceMajor = parseFloat(unitPriceStr);
// Convert to cents
const unitPrice = Math.round(unitPriceMajor * 100); const unitPrice = Math.round(unitPriceMajor * 100);
const amount = Math.round(quantity * unitPrice); const amount = Math.round(quantity * unitPrice);
@@ -77,7 +76,6 @@ export const POST: APIRoute = async ({
amount, amount,
}); });
// Update invoice totals
await recalculateInvoiceTotals(invoiceId); await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${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 }); return new Response("Invoice ID required", { status: 400 });
} }
// Fetch invoice to verify existence and check status
const invoice = await db const invoice = await db
.select() .select()
.from(invoices) .from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 }); return new Response("Invoice not found", { status: 404 });
} }
// Verify membership
const membership = await db const membership = await db
.select() .select()
.from(members) .from(members)
@@ -47,7 +45,6 @@ export const POST: APIRoute = async ({
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
// Only allow editing if draft
if (invoice.status !== "draft") { if (invoice.status !== "draft") {
return new Response("Cannot edit a finalized invoice", { status: 400 }); 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 }); return new Response("Item ID required", { status: 400 });
} }
// Verify item belongs to invoice
const item = await db const item = await db
.select() .select()
.from(invoiceItems) .from(invoiceItems)
@@ -73,7 +69,6 @@ export const POST: APIRoute = async ({
try { try {
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId)); await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
// Update invoice totals
await recalculateInvoiceTotals(invoiceId); await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`); return redirect(`/dashboard/invoices/${invoiceId}`);

View File

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

View File

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

View File

@@ -294,7 +294,7 @@ const isDraft = invoice.status === 'draft';
<span class="text-base-content/60">Subtotal</span> <span class="text-base-content/60">Subtotal</span>
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span> <span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
</div> </div>
{(invoice.discountAmount > 0) && ( {(invoice.discountAmount && invoice.discountAmount > 0) && (
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-base-content/60"> <span class="text-base-content/60">
Discount Discount

View File

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

View File

@@ -56,14 +56,14 @@ const userPasskeys = await db.select()
<PasswordForm client:load /> <PasswordForm client:load />
<!-- Passkeys --> <!-- Passkeys -->
<PasskeyManager client:load initialPasskeys={userPasskeys.map(pk => ({ <PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
...pk, ...pk,
lastUsedAt: pk.lastUsedAt ? pk.lastUsedAt.toISOString() : null, lastUsedAt: pk.lastUsedAt ? pk.lastUsedAt.toISOString() : null,
createdAt: pk.createdAt ? pk.createdAt.toISOString() : null createdAt: pk.createdAt ? pk.createdAt.toISOString() : null
}))} /> }))} />
<!-- API Tokens --> <!-- API Tokens -->
<ApiTokenManager client:load initialTokens={userTokens.map(t => ({ <ApiTokenManager client:idle initialTokens={userTokens.map(t => ({
...t, ...t,
lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null, lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null,
createdAt: t.createdAt ? t.createdAt.toISOString() : '' createdAt: t.createdAt ? t.createdAt.toISOString() : ''

View File

@@ -207,7 +207,7 @@ const paginationPages = getPaginationPages(page, totalPages);
</div> </div>
) : ( ) : (
<ManualEntry <ManualEntry
client:load client:idle
clients={allClients.map(c => ({ id: c.id, name: c.name }))} clients={allClients.map(c => ({ id: c.id, name: c.name }))}
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))} 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 }))} 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 Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import PasskeyLogin from '../components/auth/PasskeyLogin.vue';
if (Astro.locals.user) { if (Astro.locals.user) {
return Astro.redirect('/dashboard'); return Astro.redirect('/dashboard');
@@ -16,40 +17,6 @@ const errorMessage =
--- ---
<Layout title="Login - Chronus"> <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="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 bg-base-100 shadow-2xl w-full max-w-md mx-4">
<div class="card-body"> <div class="card-body">
@@ -94,10 +61,7 @@ const errorMessage =
<button class="btn btn-primary w-full mt-6">Sign In</button> <button class="btn btn-primary w-full mt-6">Sign In</button>
</form> </form>
<button id="passkey-login" class="btn btn-secondary w-full mt-4"> <PasskeyLogin client:idle />
<Icon name="heroicons:finger-print" class="w-5 h-5 mr-2" />
Sign in with Passkey
</button>
<div class="divider">OR</div> <div class="divider">OR</div>

View File

@@ -24,7 +24,6 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
.all(); .all();
// Calculate totals // Calculate totals
// Note: amounts are in cents
const subtotal = items.reduce((acc, item) => acc + item.amount, 0); const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
// Calculate discount // Calculate discount