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">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
import { ref, onMounted } from "vue";
import { Icon } from "@iconify/vue";
interface ApiToken {
id: string;
@@ -16,12 +16,17 @@ const props = defineProps<{
const tokens = ref<ApiToken[]>(props.initialTokens);
const createModalOpen = ref(false);
const showTokenModalOpen = ref(false);
const newTokenName = ref('');
const newTokenValue = ref('');
const newTokenName = ref("");
const newTokenValue = ref("");
const loading = ref(false);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
function formatDate(dateString: string | null) {
if (!dateString) return 'Never';
if (!dateString) return "Never";
return new Date(dateString).toLocaleDateString();
}
@@ -30,10 +35,10 @@ async function createToken() {
loading.value = true;
try {
const response = await fetch('/api/user/tokens', {
method: 'POST',
const response = await fetch("/api/user/tokens", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ name: newTokenName.value }),
});
@@ -48,42 +53,46 @@ async function createToken() {
id: tokenMeta.id,
name: tokenMeta.name,
lastUsedAt: tokenMeta.lastUsedAt,
createdAt: tokenMeta.createdAt
createdAt: tokenMeta.createdAt,
});
newTokenValue.value = token;
createModalOpen.value = false;
showTokenModalOpen.value = true;
newTokenName.value = '';
newTokenName.value = "";
} else {
alert('Failed to create token');
alert("Failed to create token");
}
} catch (error) {
console.error('Error creating token:', error);
alert('An error occurred');
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.')) {
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'
method: "DELETE",
});
if (response.ok) {
tokens.value = tokens.value.filter(t => t.id !== id);
tokens.value = tokens.value.filter((t) => t.id !== id);
} else {
alert('Failed to delete token');
alert("Failed to delete token");
}
} catch (error) {
console.error('Error deleting token:', error);
alert('An error occurred');
console.error("Error deleting token:", error);
alert("An error occurred");
}
}
@@ -93,7 +102,7 @@ function copyToken() {
function closeShowTokenModal() {
showTokenModalOpen.value = false;
newTokenValue.value = '';
newTokenValue.value = "";
}
</script>
@@ -103,10 +112,16 @@ function closeShowTokenModal() {
<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" />
<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">
<button
class="btn btn-primary btn-sm"
@click="createModalOpen = true"
>
<Icon icon="heroicons:plus" class="w-4 h-4" />
Create Token
</button>
@@ -131,10 +146,16 @@ function closeShowTokenModal() {
<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) }}
<span v-if="isMounted">{{
formatDate(token.lastUsedAt)
}}</span>
<span v-else>{{ token.lastUsedAt || "Never" }}</span>
</td>
<td class="text-sm">
{{ formatDate(token.createdAt) }}
<span v-if="isMounted">{{
formatDate(token.createdAt)
}}</span>
<span v-else>{{ token.createdAt }}</span>
</td>
<td>
<button
@@ -175,15 +196,24 @@ function closeShowTokenModal() {
</div>
<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">
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<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">
<form
method="dialog"
class="modal-backdrop"
@click="createModalOpen = false"
>
<button>close</button>
</form>
</dialog>
@@ -196,10 +226,13 @@ function closeShowTokenModal() {
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!
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">
<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"
@@ -211,11 +244,13 @@ function closeShowTokenModal() {
</div>
<div class="modal-action">
<button class="btn btn-primary" @click="closeShowTokenModal">Done</button>
<button class="btn btn-primary" @click="closeShowTokenModal">
Done
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" @click="closeShowTokenModal">
<button>close</button>
<button>close</button>
</form>
</dialog>
</div>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
import { startRegistration } from '@simplewebauthn/browser';
import { ref, onMounted } from "vue";
import { Icon } from "@iconify/vue";
import { startRegistration } from "@simplewebauthn/browser";
interface Passkey {
id: string;
@@ -17,9 +17,14 @@ const props = defineProps<{
const passkeys = ref<Passkey[]>(props.initialPasskeys);
const loading = ref(false);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
function formatDate(dateString: string | null) {
if (!dateString) return 'N/A';
if (!dateString) return "N/A";
return new Date(dateString).toLocaleDateString();
}
@@ -30,7 +35,7 @@ async function registerPasskey() {
const resp = await fetch("/api/auth/passkey/register/start");
if (!resp.ok) {
throw new Error("Failed to start registration");
throw new Error("Failed to start registration");
}
const options = await resp.json();
@@ -40,24 +45,21 @@ async function registerPasskey() {
try {
attResp = await startRegistration(options);
} catch (error) {
if ((error as any).name === 'NotAllowedError') {
// User cancelled or timed out
return;
if ((error as any).name === "NotAllowedError") {
// User cancelled or timed out
return;
}
console.error(error);
alert('Failed to register passkey: ' + (error as any).message);
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 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) {
@@ -68,32 +70,32 @@ async function registerPasskey() {
alert("Passkey registration failed");
}
} catch (error) {
console.error('Error registering passkey:', error);
alert('An error occurred');
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?')) {
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'
method: "DELETE",
});
if (response.ok) {
// Optimistically remove from list
passkeys.value = passkeys.value.filter(pk => pk.id !== id);
passkeys.value = passkeys.value.filter((pk) => pk.id !== id);
} else {
alert('Failed to delete passkey');
alert("Failed to delete passkey");
}
} catch (error) {
console.error('Error deleting passkey:', error);
alert('An error occurred');
console.error("Error deleting passkey:", error);
alert("An error occurred");
}
}
</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" />
Passkeys
</h2>
<button class="btn btn-primary btn-sm" @click="registerPasskey" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
<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>
@@ -131,14 +140,24 @@ async function deletePasskey(id: string) {
</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>
{{
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' }}
<span v-if="isMounted">
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : "Never" }}
</span>
<span v-else>{{ pk.lastUsedAt || "Never" }}</span>
</td>
<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>
<button

View File

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

View File

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

View File

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

View File

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