Passkeys!
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled

This commit is contained in:
2026-01-19 15:53:05 -07:00
parent bf2a1816db
commit ee9807e8e0
18 changed files with 1358 additions and 360 deletions

View File

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

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

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

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

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