diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..8ee54e8
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,30 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..114cf21
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,29 @@
+# frontend
+
+This template should help get you started developing with Vue 3 in Vite.
+
+## Recommended IDE Setup
+
+[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
+
+## Customize configuration
+
+See [Vite Configuration Reference](https://vite.dev/config/).
+
+## Project Setup
+
+```sh
+npm install
+```
+
+### Compile and Hot-Reload for Development
+
+```sh
+npm run dev
+```
+
+### Compile and Minify for Production
+
+```sh
+npm run build
+```
diff --git a/frontend/bun.lockb b/frontend/bun.lockb
new file mode 100755
index 0000000..b188daa
Binary files /dev/null and b/frontend/bun.lockb differ
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..b19040a
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite App
+
+
+
+
+
+
diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json
new file mode 100644
index 0000000..5a1f2d2
--- /dev/null
+++ b/frontend/jsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..ba4d74d
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "frontend",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@simplewebauthn/browser": "^13.1.2",
+ "pinia": "^3.0.3",
+ "qrcode": "^1.5.4",
+ "vue": "^3.5.17"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.0",
+ "vite": "^7.0.4",
+ "vite-plugin-vue-devtools": "^7.7.7"
+ }
+}
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
new file mode 100644
index 0000000..df36fcf
Binary files /dev/null and b/frontend/public/favicon.ico differ
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644
index 0000000..d2e6af6
--- /dev/null
+++ b/frontend/src/App.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css
new file mode 100644
index 0000000..52c0c55
--- /dev/null
+++ b/frontend/src/assets/style.css
@@ -0,0 +1,482 @@
+/* Passkey Authentication - Main Styles */
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ margin: 0;
+ padding: 0;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.container {
+ background: white;
+ padding: 40px;
+ border-radius: 15px;
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
+ width: 100%;
+ max-width: 400px;
+ text-align: center;
+}
+
+.view {
+ display: none;
+}
+
+.view.active {
+ display: block;
+}
+
+h1 {
+ color: #333;
+ margin-bottom: 30px;
+ font-weight: 300;
+ font-size: 28px;
+}
+
+h2 {
+ color: #555;
+ margin-bottom: 20px;
+ font-weight: 400;
+ font-size: 22px;
+}
+
+input[type="text"] {
+ width: 100%;
+ padding: 15px;
+ border: 2px solid #e1e5e9;
+ border-radius: 8px;
+ font-size: 16px;
+ margin-bottom: 20px;
+ box-sizing: border-box;
+ transition: border-color 0.3s ease;
+}
+
+input[type="text"]:focus {
+ outline: none;
+ border-color: #667eea;
+}
+
+button {
+ width: 100%;
+ padding: 15px;
+ margin-bottom: 15px;
+ font-size: 16px;
+ font-weight: 500;
+ cursor: pointer;
+ border: none;
+ border-radius: 8px;
+ transition: all 0.3s ease;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
+}
+
+.btn-secondary {
+ background: transparent;
+ color: #667eea;
+ border: 2px solid #667eea;
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: #667eea;
+ color: white;
+}
+
+.btn-danger {
+ background: #dc3545;
+ color: white;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: #c82333;
+}
+
+button:disabled {
+ background: #ccc !important;
+ cursor: not-allowed !important;
+ transform: none !important;
+ box-shadow: none !important;
+}
+
+.status {
+ padding: 10px;
+ margin: 15px 0;
+ border-radius: 5px;
+ font-size: 14px;
+}
+
+.status.success {
+ background: #d4edda;
+ color: #155724;
+ border: 1px solid #c3e6cb;
+}
+
+.status.error {
+ background: #f8d7da;
+ color: #721c24;
+ border: 1px solid #f5c6cb;
+}
+
+.status.info {
+ background: #d1ecf1;
+ color: #0c5460;
+ border: 1px solid #bee5eb;
+}
+
+.credential-list {
+ max-height: 300px;
+ overflow-y: auto;
+ margin: 20px 0;
+}
+
+.credential-item {
+ background: #f8f9fa;
+ border: 1px solid #e9ecef;
+ border-radius: 8px;
+ padding: 15px;
+ margin: 10px 0;
+ text-align: left;
+}
+
+.credential-item.current-session {
+ border: 2px solid #007bff;
+ background: #f8f9ff;
+ box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
+}
+
+.credential-item.current-session .credential-info h4 {
+ color: #0056b3;
+}
+
+.credential-header {
+ display: grid;
+ grid-template-columns: 32px 1fr auto auto;
+ gap: 12px;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.credential-icon {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.auth-icon {
+ border-radius: 4px;
+ width: 32px;
+ height: 32px;
+}
+
+.auth-emoji {
+ font-size: 24px;
+ display: block;
+ text-align: center;
+}
+
+.credential-info {
+ min-width: 0;
+}
+
+.credential-info h4 {
+ margin: 0;
+ color: #333;
+ font-size: 16px;
+}
+
+.credential-dates {
+ text-align: right;
+ flex-shrink: 0;
+ margin-left: 20px;
+ display: grid;
+ grid-template-columns: auto auto;
+ gap: 5px 10px;
+ align-items: center;
+}
+
+.date-label {
+ color: #666;
+ font-weight: normal;
+ font-size: 12px;
+ text-align: right;
+}
+
+.date-value {
+ color: #333;
+ font-size: 12px;
+ text-align: left;
+}
+
+.user-info {
+ background: #e7f3ff;
+ border: 1px solid #bee5eb;
+ border-radius: 8px;
+ padding: 15px;
+ margin: 20px 0;
+}
+
+.user-info h3 {
+ margin: 0 0 10px 0;
+ color: #0c5460;
+}
+
+.user-info p {
+ margin: 5px 0;
+ color: #0c5460;
+}
+
+.toggle-link {
+ color: #667eea;
+ text-decoration: underline;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.toggle-link:hover {
+ color: #764ba2;
+}
+
+.hidden {
+ display: none;
+}
+
+.credential-actions {
+ display: flex;
+ align-items: center;
+}
+
+.btn-delete-credential {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 16px;
+ color: #dc3545;
+ transition: background-color 0.2s;
+}
+
+.btn-delete-credential:hover:not(:disabled) {
+ background-color: #f8d7da;
+}
+
+.btn-delete-credential:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+.token-info {
+ background: #f5f5f5;
+ padding: 15px;
+ border-radius: 8px;
+ margin: 15px 0;
+ text-align: left;
+}
+
+.token-info strong {
+ color: #333;
+}
+
+.token-info code {
+ background: #e9ecef;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-family: monospace;
+}
+
+.qr-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin: 20px 0;
+}
+
+.qr-code {
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ padding: 10px;
+ background: white;
+ margin: 10px 0;
+}
+
+.link-container {
+ background: #f8f9fa;
+ border: 1px solid #e9ecef;
+ border-radius: 8px;
+ padding: 15px;
+ margin: 10px 0;
+ word-break: break-all;
+}
+
+.link-container .link-text {
+ font-family: monospace;
+ font-size: 14px;
+ color: #495057;
+ margin: 0;
+}
+
+/* Global Status Styles */
+.global-status {
+ position: fixed;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10000;
+ min-width: 300px;
+ max-width: 600px;
+ display: none;
+ animation: slideDown 0.3s ease-out;
+}
+
+.global-status .status {
+ margin: 0;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ border-width: 2px;
+ font-weight: 500;
+ padding: 12px 20px;
+ border-radius: 8px;
+ text-align: center;
+}
+
+.status.info {
+ background: #d1ecf1;
+ color: #0c5460;
+ border-color: #bee5eb;
+}
+
+.status.success {
+ background: #d4edda;
+ color: #155724;
+ border-color: #c3e6cb;
+}
+
+.status.error {
+ background: #f8d7da;
+ color: #721c24;
+ border-color: #f5c6cb;
+}
+
+@keyframes slideDown {
+ from {
+ transform: translateX(-50%) translateY(-100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(-50%) translateY(0);
+ opacity: 1;
+ }
+}
+
+/* Vue-specific styles */
+[v-cloak] {
+ display: none;
+}
+
+/* Dialog overlay and modal styles */
+.dialog-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: fadeIn 0.3s ease-out;
+}
+
+.device-dialog {
+ background: white;
+ padding: 30px;
+ border-radius: 15px;
+ box-shadow: 0 10px 30px rgba(0,0,0,0.3);
+ width: 90%;
+ max-width: 500px;
+ max-height: 90vh;
+ overflow-y: auto;
+ border: none;
+ animation: slideUp 0.3s ease-out;
+}
+
+.device-link-section {
+ margin: 20px 0;
+}
+
+.token-info {
+ text-align: center;
+}
+
+.token-display {
+ margin: 15px 0;
+ padding: 10px;
+ background: #f8f9fa;
+ border-radius: 8px;
+}
+
+.token-display code {
+ font-size: 16px;
+ font-weight: bold;
+ color: #495057;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(50px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+/* Responsive improvements */
+@media (max-width: 600px) {
+ .container {
+ margin: 20px;
+ padding: 30px 20px;
+ max-width: none;
+ }
+
+ .device-dialog {
+ margin: 20px;
+ padding: 20px;
+ max-width: none;
+ }
+
+ .global-status {
+ left: 20px;
+ right: 20px;
+ transform: none;
+ min-width: auto;
+ }
+
+ .credential-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+
+ .credential-dates {
+ width: 100%;
+ }
+}
diff --git a/frontend/src/components/AddDeviceCredentialView.vue b/frontend/src/components/AddDeviceCredentialView.vue
new file mode 100644
index 0000000..89e217c
--- /dev/null
+++ b/frontend/src/components/AddDeviceCredentialView.vue
@@ -0,0 +1,55 @@
+
+
+
+
🔑 Add Device Credential
+
+
+
+
+
+
diff --git a/frontend/src/components/DeviceLinkView.vue b/frontend/src/components/DeviceLinkView.vue
new file mode 100644
index 0000000..35be61f
--- /dev/null
+++ b/frontend/src/components/DeviceLinkView.vue
@@ -0,0 +1,69 @@
+
+
+
+
📱 Add Device
+
+
+
Device Addition Link
+
+
+
+ Scan and visit the URL on another device.
+ ⚠️ Expires in 24 hours and can only be used once.
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/LoginView.vue b/frontend/src/components/LoginView.vue
new file mode 100644
index 0000000..a1a0d2c
--- /dev/null
+++ b/frontend/src/components/LoginView.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue
new file mode 100644
index 0000000..e89214b
--- /dev/null
+++ b/frontend/src/components/ProfileView.vue
@@ -0,0 +1,185 @@
+
+
+
+
👋 Welcome!
+
+
👤 {{ authStore.currentUser.user_name }}
+ Visits:
+ {{ authStore.currentUser.visits || 0 }}
+ Registered:
+ {{ formatDate(authStore.currentUser.created_at) }}
+ Last seen:
+ {{ formatDate(authStore.currentUser.last_seen) }}
+
+
+
Your Passkeys
+
+
+
Loading credentials...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/RegisterView.vue b/frontend/src/components/RegisterView.vue
new file mode 100644
index 0000000..532c4a8
--- /dev/null
+++ b/frontend/src/components/RegisterView.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
diff --git a/frontend/src/components/StatusMessage.vue b/frontend/src/components/StatusMessage.vue
new file mode 100644
index 0000000..f96d8f2
--- /dev/null
+++ b/frontend/src/components/StatusMessage.vue
@@ -0,0 +1,13 @@
+
+
+
+ {{ authStore.status.message }}
+
+
+
+
+
diff --git a/frontend/src/main.js b/frontend/src/main.js
new file mode 100644
index 0000000..cf77201
--- /dev/null
+++ b/frontend/src/main.js
@@ -0,0 +1,11 @@
+import './assets/style.css'
+
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+
+const app = createApp(App)
+
+app.use(createPinia())
+
+app.mount('#app')
diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js
new file mode 100644
index 0000000..38a136d
--- /dev/null
+++ b/frontend/src/stores/auth.js
@@ -0,0 +1,161 @@
+import { defineStore } from 'pinia'
+import { registerUser, authenticateUser, registerWithToken } from '@/utils/passkey'
+import aWebSocket from '@/utils/awaitable-websocket'
+
+export const useAuthStore = defineStore('auth', {
+ state: () => ({
+ // Auth State
+ currentUser: null,
+ isLoading: false,
+
+ // UI State
+ currentView: 'login', // 'login', 'register', 'profile', 'device-link'
+ status: {
+ message: '',
+ type: 'info',
+ show: false
+ },
+ }),
+ actions: {
+ showMessage(message, type = 'info', duration = 3000) {
+ this.status = {
+ message,
+ type,
+ show: true
+ }
+ if (duration > 0) {
+ setTimeout(() => {
+ this.status.show = false
+ }, duration)
+ }
+ },
+ async validateStoredToken() {
+ try {
+ const response = await fetch('/auth/validate-token')
+ const result = await response.json()
+ return result.status === 'success'
+ } catch (error) {
+ return false
+ }
+ },
+ async setSessionCookie(sessionToken) {
+ const response = await fetch('/auth/set-session', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${sessionToken}`,
+ 'Content-Type': 'application/json'
+ },
+ })
+ const result = await response.json()
+ if (result.error) {
+ throw new Error(result.error)
+ }
+ return result
+ },
+ async register(user_name) {
+ this.isLoading = true
+ try {
+ const result = await registerUser(user_name)
+
+ await this.setSessionCookie(result.session_token)
+
+ this.currentUser = {
+ user_id: result.user_id,
+ user_name: user_name,
+ }
+
+ return result
+ } finally {
+ this.isLoading = false
+ }
+ },
+ async authenticate() {
+ this.isLoading = true
+ try {
+ const result = await authenticateUser()
+
+ await this.setSessionCookie(result.session_token)
+ await this.loadUserInfo()
+
+ return result
+ } finally {
+ this.isLoading = false
+ }
+ },
+ async loadUserInfo() {
+ const response = await fetch('/auth/user-info')
+ const result = await response.json()
+ if (result.error) throw new Error(`Server: ${result.error}`)
+
+ this.currentUser = result.user
+ },
+ async loadCredentials() {
+ this.isLoading = true
+ try {
+ const response = await fetch('/auth/user-credentials')
+ const result = await response.json()
+ if (result.error) throw new Error(`Server: ${result.error}`)
+
+ this.currentCredentials = result.credentials
+ this.aaguidInfo = result.aaguid_info || {}
+ } finally {
+ this.isLoading = false
+ }
+ },
+ async addNewCredential() {
+ this.isLoading = true;
+ try {
+ const result = await registerWithToken()
+ await this.loadCredentials()
+ return result;
+ } catch (error) {
+ throw new Error(`Failed to add new credential: ${error.message}`)
+ } finally {
+ this.isLoading = false
+ }
+ },
+ async deleteCredential(credentialId) {
+ const response = await fetch('/auth/delete-credential', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ credential_id: credentialId })
+ })
+ const result = await response.json()
+ if (result.error) throw new Error(`Server: ${result.error}`)
+
+ await this.loadCredentials()
+ },
+ async logout() {
+ try {
+ await fetch('/auth/logout', {method: 'POST'})
+ } catch (error) {
+ console.error('Logout error:', error)
+ }
+
+ this.currentUser = null
+ this.currentCredentials = []
+ this.aaguidInfo = {}
+ },
+ async checkResetCookieAndRegister() {
+ const passphrase = getCookie('reset')
+ if (passphrase) {
+ // Abandon existing session
+ await fetch('/auth/logout', { method: 'POST', credentials: 'include' })
+
+ // Register additional token for the user
+ try {
+ const result = await registerUserFromCookie()
+ await this.setSessionCookie(result.session_token)
+ this.currentUser = {
+ user_id: result.user_id,
+ user_name: result.user_name,
+ }
+ } catch (error) {
+ console.error('Failed to register additional token:', error)
+ }
+ }
+ },
+ }
+})
diff --git a/frontend/src/utils/awaitable-websocket.js b/frontend/src/utils/awaitable-websocket.js
new file mode 100644
index 0000000..34dab89
--- /dev/null
+++ b/frontend/src/utils/awaitable-websocket.js
@@ -0,0 +1,83 @@
+class AwaitableWebSocket extends WebSocket {
+ #received = []
+ #waiting = []
+ #err = null
+ #opened = false
+
+ constructor(resolve, reject, url, protocols, binaryType) {
+ super(url, protocols)
+ this.binaryType = binaryType || 'blob'
+ this.onopen = () => {
+ this.#opened = true
+ resolve(this)
+ }
+ this.onmessage = e => {
+ if (this.#waiting.length) this.#waiting.shift().resolve(e.data)
+ else this.#received.push(e.data)
+ }
+ this.onclose = e => {
+ if (!this.#opened) {
+ reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`))
+ return
+ }
+ this.#err = e.wasClean
+ ? new Error(`Websocket ${this.url} closed ${e.code}`)
+ : new Error(`WebSocket ${this.url} closed with error ${e.code}`)
+ this.#waiting.splice(0).forEach(p => p.reject(this.#err))
+ }
+ }
+
+ receive() {
+ // If we have a message already received, return it immediately
+ if (this.#received.length) return Promise.resolve(this.#received.shift())
+ // Wait for incoming messages, if we have an error, reject immediately
+ if (this.#err) return Promise.reject(this.#err)
+ return new Promise((resolve, reject) => this.#waiting.push({ resolve, reject }))
+ }
+
+ async receive_bytes() {
+ const data = await this.receive()
+ if (typeof data === 'string') {
+ console.error("WebSocket received text data, expected a binary message", data)
+ throw new Error("WebSocket received text data, expected a binary message")
+ }
+ return data instanceof Blob ? data.bytes() : new Uint8Array(data)
+ }
+
+ async receive_json() {
+ const data = await this.receive()
+ if (typeof data !== 'string') {
+ console.error("WebSocket received binary data, expected JSON string", data)
+ throw new Error("WebSocket received binary data, expected JSON string")
+ }
+ let parsed
+ try {
+ parsed = JSON.parse(data)
+ } catch (err) {
+ console.error("Failed to parse JSON from WebSocket message", data, err)
+ throw new Error("Failed to parse JSON from WebSocket message")
+ }
+ if (parsed.error) {
+ throw new Error(`Server: ${parsed.error}`)
+ }
+ return parsed
+ }
+
+ send_json(data) {
+ let jsonData
+ try {
+ jsonData = JSON.stringify(data)
+ } catch (err) {
+ throw new Error(`Failed to stringify data for WebSocket: ${err.message}`)
+ }
+ this.send(jsonData)
+ }
+}
+
+// Construct an async WebSocket with await aWebSocket(url)
+export default function aWebSocket(url, options = {}) {
+ const { protocols, binaryType } = options
+ return new Promise((resolve, reject) => {
+ new AwaitableWebSocket(resolve, reject, url, protocols, binaryType)
+ })
+}
diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js
new file mode 100644
index 0000000..9b38ff3
--- /dev/null
+++ b/frontend/src/utils/helpers.js
@@ -0,0 +1,24 @@
+// Utility functions
+
+export function formatDate(dateString) {
+ if (!dateString) return 'Never'
+
+ const date = new Date(dateString)
+ const now = new Date()
+ const diffMs = now - date
+ const diffMinutes = Math.floor(diffMs / (1000 * 60))
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
+
+ if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString()
+ if (diffMinutes === 0) return 'Just now'
+ if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
+ if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
+ return diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
+}
+
+export function getCookie(name) {
+ const value = `; ${document.cookie}`
+ const parts = value.split(`; ${name}=`)
+ if (parts.length === 2) return parts.pop().split(';').shift()
+}
diff --git a/frontend/src/utils/passkey.js b/frontend/src/utils/passkey.js
new file mode 100644
index 0000000..0a74dea
--- /dev/null
+++ b/frontend/src/utils/passkey.js
@@ -0,0 +1,38 @@
+import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
+import aWebSocket from '@/utils/awaitable-websocket'
+
+export async function register(url, options) {
+ if (options) url += `?${new URLSearchParams(options).toString()}`
+ const ws = await aWebSocket(url)
+
+ const optionsJSON = await ws.receive_json()
+ const registrationResponse = await startRegistration({ optionsJSON })
+ ws.send_json(registrationResponse)
+
+ const result = await ws.receive_json()
+ ws.close()
+ return result;
+}
+
+export async function registerUser(user_name) {
+ return register('/auth/ws/new_user_registration', { user_name })
+}
+
+export async function registerCredential() {
+ return register('/auth/ws/add_credential')
+}
+export async function registerWithToken(token) {
+ return register('/auth/ws/add_device_credential', {token})
+}
+
+export async function authenticateUser() {
+ const ws = await aWebSocket('/auth/ws/authenticate')
+
+ const optionsJSON = await ws.receive_json()
+ const authResponse = await startAuthentication({ optionsJSON })
+ ws.send_json(authResponse)
+
+ const result = await ws.receive_json()
+ ws.close()
+ return result
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..dad8e01
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,33 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueDevTools from 'vite-plugin-vue-devtools'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [
+ vue(),
+ vueDevTools(),
+ ],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url))
+ },
+ },
+ server: {
+ port: 3000,
+ proxy: {
+ '/auth/': {
+ target: 'http://localhost:8000',
+ ws: true,
+ changeOrigin: false
+ }
+ }
+ },
+ build: {
+ outDir: '../static/dist',
+ emptyOutDir: true,
+ assetsDir: 'assets'
+ }
+})