Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d4a2c5853b
|
|||
|
ee9807e8e0
|
|||
|
bf2a1816db
|
|||
|
1063bf99f1
|
|||
|
ea0a83f44d
|
|||
|
fa2c92644a
|
16
drizzle/0002_chilly_cyclops.sql
Normal file
16
drizzle/0002_chilly_cyclops.sql
Normal 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`);
|
||||||
3
drizzle/0003_amusing_wendigo.sql
Normal file
3
drizzle/0003_amusing_wendigo.sql
Normal 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;
|
||||||
22
drizzle/0004_happy_namorita.sql
Normal file
22
drizzle/0004_happy_namorita.sql
Normal 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`);
|
||||||
1150
drizzle/meta/0002_snapshot.json
Normal file
1150
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
drizzle/meta/0003_snapshot.json
Normal file
1174
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1315
drizzle/meta/0004_snapshot.json
Normal file
1315
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,27 @@
|
|||||||
"when": 1768690333269,
|
"when": 1768690333269,
|
||||||
"tag": "0001_lazy_roughhouse",
|
"tag": "0001_lazy_roughhouse",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronus",
|
"name": "chronus",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.0.0",
|
"version": "2.2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
@@ -18,17 +18,20 @@
|
|||||||
"@ceereals/vue-pdf": "^0.2.1",
|
"@ceereals/vue-pdf": "^0.2.1",
|
||||||
"@iconify/vue": "^5.0.0",
|
"@iconify/vue": "^5.0.0",
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.0",
|
||||||
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"astro": "^5.16.11",
|
"astro": "^5.16.11",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"daisyui": "^5.5.14",
|
"daisyui": "^5.5.14",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.27",
|
||||||
"vue-chartjs": "^5.3.3"
|
"vue-chartjs": "^5.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
647
pnpm-lock.yaml
generated
647
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 MemberData {
|
interface MemberData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,58 +37,60 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
const chartData = computed(() => ({
|
||||||
labels: props.members.map(m => m.name),
|
labels: props.members.map((m) => m.name),
|
||||||
datasets: [{
|
datasets: [
|
||||||
label: 'Time Tracked',
|
{
|
||||||
data: props.members.map(m => m.totalTime / (1000 * 60)), // Convert to minutes
|
label: "Time Tracked",
|
||||||
backgroundColor: '#10b981',
|
data: props.members.map((m) => m.totalTime / (1000 * 60)), // Convert to minutes
|
||||||
borderColor: '#059669',
|
backgroundColor: "#10b981",
|
||||||
|
borderColor: "#059669",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
}]
|
},
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const chartOptions = {
|
const chartOptions: ChartOptions<"bar"> = {
|
||||||
indexAxis: 'y' as const,
|
indexAxis: "y" as const,
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#e2e8f0',
|
color: "#e2e8f0",
|
||||||
callback: function(value: number) {
|
callback: function (value: any) {
|
||||||
const hours = Math.floor(value / 60);
|
const hours = Math.floor(value / 60);
|
||||||
const mins = value % 60;
|
const mins = value % 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",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
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: any) {
|
||||||
const minutes = Math.round(context.raw);
|
const minutes = Math.round(context.raw);
|
||||||
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>
|
||||||
|
|||||||
222
src/components/settings/ApiTokenManager.vue
Normal file
222
src/components/settings/ApiTokenManager.vue
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
|
||||||
|
interface ApiToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialTokens: ApiToken[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const tokens = ref<ApiToken[]>(props.initialTokens);
|
||||||
|
const createModalOpen = ref(false);
|
||||||
|
const showTokenModalOpen = ref(false);
|
||||||
|
const newTokenName = ref('');
|
||||||
|
const newTokenValue = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
function formatDate(dateString: string | null) {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createToken() {
|
||||||
|
if (!newTokenName.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: newTokenName.value }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const { token, ...tokenMeta } = data;
|
||||||
|
|
||||||
|
// Add to beginning of list
|
||||||
|
tokens.value.unshift({
|
||||||
|
id: tokenMeta.id,
|
||||||
|
name: tokenMeta.name,
|
||||||
|
lastUsedAt: tokenMeta.lastUsedAt,
|
||||||
|
createdAt: tokenMeta.createdAt
|
||||||
|
});
|
||||||
|
|
||||||
|
newTokenValue.value = token;
|
||||||
|
createModalOpen.value = false;
|
||||||
|
showTokenModalOpen.value = true;
|
||||||
|
newTokenName.value = '';
|
||||||
|
} else {
|
||||||
|
alert('Failed to create token');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating token:', error);
|
||||||
|
alert('An error occurred');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteToken(id: string) {
|
||||||
|
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/user/tokens/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
tokens.value = tokens.value.filter(t => t.id !== id);
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete token');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting token:', error);
|
||||||
|
alert('An error occurred');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToken() {
|
||||||
|
navigator.clipboard.writeText(newTokenValue.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeShowTokenModal() {
|
||||||
|
showTokenModalOpen.value = false;
|
||||||
|
newTokenValue.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="card-title text-lg sm:text-xl">
|
||||||
|
<Icon icon="heroicons:code-bracket-square" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
API Tokens
|
||||||
|
</h2>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="createModalOpen = true">
|
||||||
|
<Icon icon="heroicons:plus" class="w-4 h-4" />
|
||||||
|
Create Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Last Used</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="tokens.length === 0">
|
||||||
|
<td colspan="4" class="text-center text-base-content/60 py-4">
|
||||||
|
No API tokens found. Create one to access the API.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else v-for="token in tokens" :key="token.id">
|
||||||
|
<td class="font-medium">{{ token.name }}</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ formatDate(token.lastUsedAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ formatDate(token.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
@click="deleteToken(token.id)"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:trash" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Token Modal -->
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': createModalOpen }">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg">Create API Token</h3>
|
||||||
|
<p class="py-4 text-sm text-base-content/70">
|
||||||
|
API tokens allow you to authenticate with the API programmatically.
|
||||||
|
Give your token a descriptive name.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="createToken" class="space-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium">Token Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="newTokenName"
|
||||||
|
placeholder="e.g. CI/CD Pipeline"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" @click="createModalOpen = false">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
|
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||||
|
Generate Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop" @click="createModalOpen = false">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Show Token Modal -->
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': showTokenModalOpen }">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg text-success flex items-center gap-2">
|
||||||
|
<Icon icon="heroicons:check-circle" class="w-6 h-6" />
|
||||||
|
Token Created
|
||||||
|
</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
Make sure to copy your personal access token now. You won't be able to see it again!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group">
|
||||||
|
<span>{{ newTokenValue }}</span>
|
||||||
|
<button
|
||||||
|
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
@click="copyToken"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:clipboard" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-primary" @click="closeShowTokenModal">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop" @click="closeShowTokenModal">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
157
src/components/settings/PasskeyManager.vue
Normal file
157
src/components/settings/PasskeyManager.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
|
||||||
|
interface Passkey {
|
||||||
|
id: string;
|
||||||
|
deviceType: string;
|
||||||
|
backedUp: boolean;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialPasskeys: Passkey[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const passkeys = ref<Passkey[]>(props.initialPasskeys);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
function formatDate(dateString: string | null) {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerPasskey() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// 1. Get options from server
|
||||||
|
const resp = await fetch("/api/auth/passkey/register/start");
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error("Failed to start registration");
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = await resp.json();
|
||||||
|
|
||||||
|
// 2. Browser handles interaction
|
||||||
|
let attResp;
|
||||||
|
try {
|
||||||
|
attResp = await startRegistration(options);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as any).name === 'NotAllowedError') {
|
||||||
|
// User cancelled or timed out
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(error);
|
||||||
|
alert('Failed to register passkey: ' + (error as any).message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify with server
|
||||||
|
const verificationResp = await fetch(
|
||||||
|
"/api/auth/passkey/register/finish",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(attResp),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const verificationJSON = await verificationResp.json();
|
||||||
|
if (verificationJSON.verified) {
|
||||||
|
// Reload to show the new passkey since the API doesn't return the created object
|
||||||
|
// Ideally we would return the object and append it to 'passkeys' to avoid reload
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert("Passkey registration failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error registering passkey:', error);
|
||||||
|
alert('An error occurred');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePasskey(id: string) {
|
||||||
|
if (!confirm('Are you sure you want to remove this passkey?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/auth/passkey/delete?id=${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Optimistically remove from list
|
||||||
|
passkeys.value = passkeys.value.filter(pk => pk.id !== id);
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete passkey');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting passkey:', error);
|
||||||
|
alert('An error occurred');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="card-title text-lg sm:text-xl">
|
||||||
|
<Icon icon="heroicons:finger-print" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
Passkeys
|
||||||
|
</h2>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="registerPasskey" :disabled="loading">
|
||||||
|
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
|
||||||
|
<Icon v-else icon="heroicons:plus" class="w-4 h-4" />
|
||||||
|
Add Passkey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Device Type</th>
|
||||||
|
<th>Last Used</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="passkeys.length === 0">
|
||||||
|
<td colspan="4" class="text-center text-base-content/60 py-4">
|
||||||
|
No passkeys found. Add one to sign in without a password.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else v-for="pk in passkeys" :key="pk.id">
|
||||||
|
<td class="font-medium">
|
||||||
|
{{ pk.deviceType === 'singleDevice' ? 'This Device' : 'Cross-Platform (Phone/Key)' }}
|
||||||
|
<span v-if="pk.backedUp" class="badge badge-xs badge-info ml-2">Backed Up</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : 'Never' }}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ formatDate(pk.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
@click="deletePasskey(pk.id)"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:trash" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
132
src/components/settings/PasswordForm.vue
Normal file
132
src/components/settings/PasswordForm.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
|
||||||
|
const currentPassword = ref('');
|
||||||
|
const newPassword = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
message.value = { type: 'error', text: 'New passwords do not match' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.value.length < 8) {
|
||||||
|
message.value = { type: 'error', text: 'Password must be at least 8 characters' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
message.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPassword: currentPassword.value,
|
||||||
|
newPassword: newPassword.value,
|
||||||
|
confirmPassword: confirmPassword.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
message.value = { type: 'success', text: 'Password changed successfully!' };
|
||||||
|
// Reset form
|
||||||
|
currentPassword.value = '';
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
|
||||||
|
// Hide success message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
message.value = null;
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
message.value = { type: 'error', text: data.error || 'Failed to change password' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.value = { type: 'error', text: 'An error occurred' };
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Success/Error Message Display -->
|
||||||
|
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']">
|
||||||
|
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" />
|
||||||
|
<span>{{ message.text }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
|
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||||
|
<Icon icon="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
Change Password
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="changePassword" class="space-y-5">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
v-model="currentPassword"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
v-model="newPassword"
|
||||||
|
placeholder="Enter new password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
/>
|
||||||
|
<div class="label pt-2">
|
||||||
|
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading">
|
||||||
|
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||||
|
<Icon v-else icon="heroicons:lock-closed" class="w-5 h-5" />
|
||||||
|
Update Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
103
src/components/settings/ProfileForm.vue
Normal file
103
src/components/settings/ProfileForm.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const name = ref(props.user.name);
|
||||||
|
const loading = ref(false);
|
||||||
|
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
async function updateProfile() {
|
||||||
|
loading.value = true;
|
||||||
|
message.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/update-profile', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: name.value }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
message.value = { type: 'success', text: 'Profile updated successfully!' };
|
||||||
|
// Hide success message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
message.value = null;
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
message.value = { type: 'error', text: data.error || 'Failed to update profile' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.value = { type: 'error', text: 'An error occurred' };
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Success/Error Message Display -->
|
||||||
|
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']">
|
||||||
|
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" />
|
||||||
|
<span>{{ message.text }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
|
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||||
|
<Icon icon="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
Profile Information
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="updateProfile" class="space-y-5">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="name"
|
||||||
|
placeholder="Your full name"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">Email</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
:value="props.user.email"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<div class="label pt-2">
|
||||||
|
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading">
|
||||||
|
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||||
|
<Icon v-else icon="heroicons:check" class="w-5 h-5" />
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -277,6 +277,9 @@ export const invoices = sqliteTable(
|
|||||||
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), // in cents
|
||||||
|
discountValue: real("discount_value").default(0),
|
||||||
|
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed'
|
||||||
|
discountAmount: integer("discount_amount").default(0), // in cents
|
||||||
taxRate: real("tax_rate").default(0), // percentage
|
taxRate: real("tax_rate").default(0), // percentage
|
||||||
taxAmount: integer("tax_amount").notNull().default(0), // in cents
|
taxAmount: integer("tax_amount").notNull().default(0), // in cents
|
||||||
total: integer("total").notNull().default(0), // in cents
|
total: integer("total").notNull().default(0), // in cents
|
||||||
@@ -320,3 +323,36 @@ export const invoiceItems = sqliteTable(
|
|||||||
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
|
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const passkeys = sqliteTable(
|
||||||
|
"passkeys",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(), // The Credential ID
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
publicKey: text("public_key").notNull(), // Base64 encoded public key
|
||||||
|
counter: integer("counter").notNull(),
|
||||||
|
deviceType: text("device_type").notNull(), // 'singleDevice' or 'multiDevice'
|
||||||
|
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
|
||||||
|
transports: text("transports"), // JSON stringified array
|
||||||
|
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
|
() => new Date(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
(table: any) => ({
|
||||||
|
userFk: foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [users.id],
|
||||||
|
}),
|
||||||
|
userIdIdx: index("passkeys_user_id_idx").on(table.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const passkeyChallenges = sqliteTable("passkey_challenges", {
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
challenge: text("challenge").notNull().unique(),
|
||||||
|
userId: text("user_id"),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
});
|
||||||
|
|||||||
35
src/pages/api/auth/passkey/delete/index.ts
Normal file
35
src/pages/api/auth/passkey/delete/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
102
src/pages/api/auth/passkey/login/finish.ts
Normal file
102
src/pages/api/auth/passkey/login/finish.ts
Normal 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 });
|
||||||
|
};
|
||||||
18
src/pages/api/auth/passkey/login/start.ts
Normal file
18
src/pages/api/auth/passkey/login/start.ts
Normal 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));
|
||||||
|
};
|
||||||
81
src/pages/api/auth/passkey/register/finish.ts
Normal file
81
src/pages/api/auth/passkey/register/finish.ts
Normal 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 });
|
||||||
|
};
|
||||||
45
src/pages/api/auth/passkey/register/start.ts
Normal file
45
src/pages/api/auth/passkey/register/start.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
||||||
|
import { db } from "../../../../../db";
|
||||||
|
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request, locals }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's existing passkeys to prevent registering the same authenticator twice
|
||||||
|
const userPasskeys = await db.query.passkeys.findMany({
|
||||||
|
where: eq(passkeys.userId, user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: "Chronus",
|
||||||
|
rpID: new URL(request.url).hostname,
|
||||||
|
userName: user.email,
|
||||||
|
attestationType: "none",
|
||||||
|
excludeCredentials: userPasskeys.map((passkey) => ({
|
||||||
|
id: passkey.id,
|
||||||
|
transports: passkey.transports
|
||||||
|
? JSON.parse(passkey.transports)
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "preferred",
|
||||||
|
userVerification: "preferred",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(passkeyChallenges).values({
|
||||||
|
challenge: options.challenge,
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(options));
|
||||||
|
};
|
||||||
@@ -4,12 +4,7 @@ import { invoices, members } from "../../../../db/schema";
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({
|
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
||||||
request,
|
|
||||||
redirect,
|
|
||||||
locals,
|
|
||||||
params,
|
|
||||||
}) => {
|
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return redirect("/login");
|
return redirect("/login");
|
||||||
@@ -38,8 +33,8 @@ export const POST: APIRoute = async ({
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(members.userId, user.id),
|
eq(members.userId, user.id),
|
||||||
eq(members.organizationId, invoice.organizationId)
|
eq(members.organizationId, invoice.organizationId),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
@@ -53,6 +48,8 @@ export const POST: APIRoute = async ({
|
|||||||
const issueDateStr = formData.get("issueDate") as string;
|
const issueDateStr = formData.get("issueDate") as string;
|
||||||
const dueDateStr = formData.get("dueDate") as string;
|
const dueDateStr = formData.get("dueDate") as string;
|
||||||
const taxRateStr = formData.get("taxRate") 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;
|
const notes = formData.get("notes") as string;
|
||||||
|
|
||||||
if (!number || !currency || !issueDateStr || !dueDateStr) {
|
if (!number || !currency || !issueDateStr || !dueDateStr) {
|
||||||
@@ -64,6 +61,11 @@ export const POST: APIRoute = async ({
|
|||||||
const dueDate = new Date(dueDateStr);
|
const dueDate = new Date(dueDateStr);
|
||||||
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
|
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
|
||||||
|
|
||||||
|
let discountValue = discountValueStr ? parseFloat(discountValueStr) : 0;
|
||||||
|
if (discountType === "fixed") {
|
||||||
|
discountValue = Math.round(discountValue * 100);
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
@@ -72,6 +74,8 @@ export const POST: APIRoute = async ({
|
|||||||
issueDate,
|
issueDate,
|
||||||
dueDate,
|
dueDate,
|
||||||
taxRate,
|
taxRate,
|
||||||
|
discountType: discountType as "percentage" | "fixed",
|
||||||
|
discountValue,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceId));
|
.where(eq(invoices.id, invoiceId));
|
||||||
|
|||||||
137
src/pages/api/reports/export.ts
Normal file
137
src/pages/api/reports/export.ts
Normal 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"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,61 +1,104 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { users } from '../../../db/schema';
|
import { users } from "../../../db/schema";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
|
const contentType = request.headers.get("content-type");
|
||||||
|
const isJson = contentType?.includes("application/json");
|
||||||
|
|
||||||
if (!user) {
|
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 formData = await request.formData();
|
||||||
const currentPassword = formData.get('currentPassword') as string;
|
currentPassword = formData.get("currentPassword") as string;
|
||||||
const newPassword = formData.get('newPassword') as string;
|
newPassword = formData.get("newPassword") as string;
|
||||||
const confirmPassword = formData.get('confirmPassword') as string;
|
confirmPassword = formData.get("confirmPassword") as string;
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
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) {
|
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) {
|
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 {
|
try {
|
||||||
// Get current user from database
|
// Get current user from database
|
||||||
const dbUser = await db.select()
|
const dbUser = await db
|
||||||
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, user.id))
|
.where(eq(users.id, user.id))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!dbUser) {
|
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
|
// Verify current password
|
||||||
const passwordMatch = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
const passwordMatch = await bcrypt.compare(
|
||||||
|
currentPassword,
|
||||||
|
dbUser.passwordHash,
|
||||||
|
);
|
||||||
if (!passwordMatch) {
|
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
|
// Hash new password
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||||
|
|
||||||
// Update password
|
// Update password
|
||||||
await db.update(users)
|
await db
|
||||||
|
.update(users)
|
||||||
.set({ passwordHash: hashedPassword })
|
.set({ passwordHash: hashedPassword })
|
||||||
.where(eq(users.id, user.id))
|
.where(eq(users.id, user.id))
|
||||||
.run();
|
.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) {
|
} catch (error) {
|
||||||
console.error('Error changing password:', error);
|
console.error("Error changing password:", error);
|
||||||
return new Response('Failed to change password', { status: 500 });
|
const msg = "Failed to change password";
|
||||||
|
if (isJson)
|
||||||
|
return new Response(JSON.stringify({ error: msg }), { status: 500 });
|
||||||
|
return new Response(msg, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 formData = await request.formData();
|
||||||
const name = formData.get("name")?.toString();
|
name = formData.get("name")?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return new Response(JSON.stringify({ error: "Name is required" }), {
|
return new Response(JSON.stringify({ error: "Name is required" }), {
|
||||||
|
|||||||
@@ -1,30 +1,58 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { users } from '../../../db/schema';
|
import { users } from "../../../db/schema";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
|
const contentType = request.headers.get("content-type");
|
||||||
|
const isJson = contentType?.includes("application/json");
|
||||||
|
|
||||||
if (!user) {
|
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 formData = await request.formData();
|
||||||
const name = formData.get('name') as string;
|
name = formData.get("name") as string;
|
||||||
|
}
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
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 {
|
try {
|
||||||
await db.update(users)
|
await db
|
||||||
|
.update(users)
|
||||||
.set({ name: name.trim() })
|
.set({ name: name.trim() })
|
||||||
.where(eq(users.id, user.id))
|
.where(eq(users.id, user.id))
|
||||||
.run();
|
.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) {
|
} catch (error) {
|
||||||
console.error('Error updating profile:', error);
|
console.error("Error updating profile:", error);
|
||||||
return new Response('Failed to update profile', { status: 500 });
|
const msg = "Failed to update profile";
|
||||||
|
if (isJson) {
|
||||||
|
return new Response(JSON.stringify({ error: msg }), { status: 500 });
|
||||||
|
}
|
||||||
|
return new Response(msg, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -294,6 +294,15 @@ 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) && (
|
||||||
|
<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) && (
|
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
|
||||||
<div class="flex justify-between text-sm items-center group">
|
<div class="flex justify-between text-sm items-center group">
|
||||||
<span class="text-base-content/60 flex items-center gap-2">
|
<span class="text-base-content/60 flex items-center gap-2">
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ if (!membership) {
|
|||||||
// Format dates for input[type="date"]
|
// Format dates for input[type="date"]
|
||||||
const issueDateStr = invoice.issueDate.toISOString().split('T')[0];
|
const issueDateStr = invoice.issueDate.toISOString().split('T')[0];
|
||||||
const dueDateStr = invoice.dueDate.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`}>
|
<DashboardLayout title={`Edit ${invoice.number} - Chronus`}>
|
||||||
@@ -112,6 +116,27 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Tax Rate -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ const selectedMemberId = url.searchParams.get('member') || '';
|
|||||||
const selectedCategoryId = url.searchParams.get('category') || '';
|
const selectedCategoryId = url.searchParams.get('category') || '';
|
||||||
const selectedClientId = url.searchParams.get('client') || '';
|
const selectedClientId = url.searchParams.get('client') || '';
|
||||||
const timeRange = url.searchParams.get('range') || 'week';
|
const timeRange = url.searchParams.get('range') || 'week';
|
||||||
|
const customFrom = url.searchParams.get('from');
|
||||||
|
const customTo = url.searchParams.get('to');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let startDate = new Date();
|
let startDate = new Date();
|
||||||
@@ -78,6 +80,16 @@ switch (timeRange) {
|
|||||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||||
break;
|
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 = [
|
const conditions = [
|
||||||
@@ -250,6 +262,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
case 'mtd': return 'Month to Date';
|
case 'mtd': return 'Month to Date';
|
||||||
case 'ytd': return 'Year to Date';
|
case 'ytd': return 'Year to Date';
|
||||||
case 'last-month': return 'Last Month';
|
case 'last-month': return 'Last Month';
|
||||||
|
case 'custom': return 'Custom Range';
|
||||||
default: return 'Last 7 Days';
|
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="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
|
||||||
<option value="ytd" selected={timeRange === 'ytd'}>Year 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="last-month" selected={timeRange === 'last-month'}>Last Month</option>
|
||||||
|
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Team Member</span>
|
<span class="label-text font-medium">Team Member</span>
|
||||||
@@ -328,7 +371,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
select {
|
select, input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -709,10 +752,18 @@ function getTimeRangeLabel(range: string) {
|
|||||||
{/* Detailed Entries */}
|
{/* Detailed Entries */}
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||||
<div class="card-body">
|
<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" />
|
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||||
Detailed Entries ({entries.length})
|
Detailed Entries ({entries.length})
|
||||||
</h2>
|
</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 ? (
|
{entries.length > 0 ? (
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { apiTokens } from '../../db/schema';
|
import { apiTokens, passkeys } from '../../db/schema';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
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;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
@@ -16,6 +20,12 @@ const userTokens = await db.select()
|
|||||||
.where(eq(apiTokens.userId, user.id))
|
.where(eq(apiTokens.userId, user.id))
|
||||||
.orderBy(desc(apiTokens.createdAt))
|
.orderBy(desc(apiTokens.createdAt))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
const userPasskeys = await db.select()
|
||||||
|
.from(passkeys)
|
||||||
|
.where(eq(passkeys.userId, user.id))
|
||||||
|
.orderBy(desc(passkeys.createdAt))
|
||||||
|
.all();
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Account Settings - Chronus">
|
<DashboardLayout title="Account Settings - Chronus">
|
||||||
@@ -40,177 +50,25 @@ const userTokens = await db.select()
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Profile Information -->
|
<!-- Profile Information -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<ProfileForm client:load user={user} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Change Password -->
|
<!-- Change Password -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<PasswordForm client:load />
|
||||||
<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>
|
|
||||||
|
|
||||||
<form action="/api/user/change-password" method="POST" class="space-y-5">
|
<!-- Passkeys -->
|
||||||
<div class="form-control">
|
<PasskeyManager client:load initialPasskeys={userPasskeys.map(pk => ({
|
||||||
<label class="label pb-2">
|
...pk,
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
|
lastUsedAt: pk.lastUsedAt ? pk.lastUsedAt.toISOString() : null,
|
||||||
</label>
|
createdAt: pk.createdAt ? pk.createdAt.toISOString() : null
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- API Tokens -->
|
<!-- API Tokens -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<ApiTokenManager client:load initialTokens={userTokens.map(t => ({
|
||||||
<div class="card-body p-4 sm:p-6">
|
...t,
|
||||||
<div class="flex justify-between items-center mb-6">
|
lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null,
|
||||||
<h2 class="card-title text-lg sm:text-xl">
|
createdAt: t.createdAt ? t.createdAt.toISOString() : ''
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 bg-base-100 shadow-xl border border-base-200">
|
||||||
<div class="card-body p-4 sm:p-6">
|
<div class="card-body p-4 sm:p-6">
|
||||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||||
@@ -238,132 +96,5 @@ const userTokens = await db.select()
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -16,6 +16,40 @@ 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">
|
||||||
@@ -60,6 +94,11 @@ 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">
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ interface Invoice {
|
|||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
currency: string;
|
currency: string;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
|
discountValue: number | null;
|
||||||
|
discountType: string | null;
|
||||||
|
discountAmount: number | null;
|
||||||
taxRate: number | null;
|
taxRate: number | null;
|
||||||
taxAmount: number;
|
taxAmount: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -503,6 +506,24 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
|
|||||||
formatCurrency(invoice.subtotal),
|
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
|
(invoice.taxRate ?? 0) > 0
|
||||||
? h(View, { style: styles.totalRow }, [
|
? h(View, { style: styles.totalRow }, [
|
||||||
h(
|
h(
|
||||||
|
|||||||
@@ -27,16 +27,34 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
|
|||||||
// Note: amounts are in cents
|
// 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);
|
||||||
|
|
||||||
const taxRate = invoice.taxRate || 0;
|
// Calculate discount
|
||||||
const taxAmount = Math.round(subtotal * (taxRate / 100));
|
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
|
// Update invoice
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
subtotal,
|
subtotal,
|
||||||
|
discountAmount,
|
||||||
taxAmount,
|
taxAmount,
|
||||||
total,
|
total,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user