Optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m5s

This commit is contained in:
2026-01-19 20:55:47 -07:00
parent d4a2c5853b
commit 8a3932a013
7 changed files with 200 additions and 106 deletions

View File

@@ -0,0 +1,76 @@
<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 {
// 1. Get options from server
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();
// 2. Browser handles interaction
let asseResp;
try {
asseResp = await startAuthentication(options);
} catch (err) {
if ((err as any).name === 'NotAllowedError') {
// User cancelled or timed out
return;
}
console.error(err);
error.value = 'Failed to authenticate with passkey';
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 {
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 }),
}); });
@@ -48,42 +53,46 @@ async function createToken() {
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 +102,7 @@ function copyToken() {
function closeShowTokenModal() { function closeShowTokenModal() {
showTokenModalOpen.value = false; showTokenModalOpen.value = false;
newTokenValue.value = ''; newTokenValue.value = "";
} }
</script> </script>
@@ -103,10 +112,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 +146,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 +196,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 +226,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 +244,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,9 +17,14 @@ 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();
} }
@@ -30,7 +35,7 @@ async function registerPasskey() {
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();
@@ -40,24 +45,21 @@ async function registerPasskey() {
try { try {
attResp = await startRegistration(options); attResp = await startRegistration(options);
} catch (error) { } catch (error) {
if ((error as any).name === 'NotAllowedError') { if ((error as any).name === "NotAllowedError") {
// User cancelled or timed out // 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 // 3. Verify with server
const verificationResp = await fetch( const verificationResp = await fetch("/api/auth/passkey/register/finish", {
"/api/auth/passkey/register/finish", method: "POST",
{ headers: { "Content-Type": "application/json" },
method: "POST", body: JSON.stringify(attResp),
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) {
@@ -68,32 +70,32 @@ async function registerPasskey() {
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 // 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 +108,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 +140,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

@@ -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>