From 9711453553d32890fccd2312eac63e0e6dd3c4f3 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 13 Jul 2025 12:41:08 -0600 Subject: [PATCH] Rewritten frontend with Vue --- frontend/.gitignore | 30 ++ frontend/README.md | 29 ++ frontend/bun.lockb | Bin 0 -> 88484 bytes frontend/index.html | 13 + frontend/jsconfig.json | 8 + frontend/package.json | 22 + frontend/public/favicon.ico | Bin 0 -> 4286 bytes frontend/src/App.vue | 46 ++ frontend/src/assets/style.css | 482 ++++++++++++++++++ .../components/AddDeviceCredentialView.vue | 55 ++ frontend/src/components/DeviceLinkView.vue | 69 +++ frontend/src/components/LoginView.vue | 39 ++ frontend/src/components/ProfileView.vue | 185 +++++++ frontend/src/components/RegisterView.vue | 57 +++ frontend/src/components/StatusMessage.vue | 13 + frontend/src/main.js | 11 + frontend/src/stores/auth.js | 161 ++++++ frontend/src/utils/awaitable-websocket.js | 83 +++ frontend/src/utils/helpers.js | 24 + frontend/src/utils/passkey.js | 38 ++ frontend/vite.config.js | 33 ++ 21 files changed, 1398 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100755 frontend/bun.lockb create mode 100644 frontend/index.html create mode 100644 frontend/jsconfig.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/style.css create mode 100644 frontend/src/components/AddDeviceCredentialView.vue create mode 100644 frontend/src/components/DeviceLinkView.vue create mode 100644 frontend/src/components/LoginView.vue create mode 100644 frontend/src/components/ProfileView.vue create mode 100644 frontend/src/components/RegisterView.vue create mode 100644 frontend/src/components/StatusMessage.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/utils/awaitable-websocket.js create mode 100644 frontend/src/utils/helpers.js create mode 100644 frontend/src/utils/passkey.js create mode 100644 frontend/vite.config.js 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 0000000000000000000000000000000000000000..b188daaf30aa0142b6616da8013536aaf362ac8a GIT binary patch literal 88484 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 + + + + + + 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 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 GIT binary patch literal 4286 zcmZQzU}RuqP*4ET3Jfa*7#PGD7#K7d7#I{77#JKFAmR)lAOIqU2X5Zs$bgLL=_@3A zjhlBkf-u-E^l}5#e(vTSjvJvE#HNe&P`g3?jcVTE_#KKtY>*hu-2k;;s(FXw$>tr7 z|DhPf28q$seyH6be^xc`aQp|g8{`HM8zcsjqlp`k?AB}E;dmd(Zjk*T3=#v$(Zmf< z`&pZJIL^XiH^_bv2FZccP&Evoc7y!o(Y(X)10MT9av(JzwN!Hh)P8~H9gaKj*bVYO z2!qss)KbNMsNEp{q&4qw{6&QQAT=PhAUzbj0cyWO^A5*L7iKh$o<<{gf0*zB%ZV*ek6akv4b2c(xQH$d$Mg`s)#4##Kc_BU;D{GXL$3C18c zx;#`5NH53?lHCBcpR;*~<1!4hcRKzrpKSX--p3S-L2Mjh0MZLGgCzT*c7xm<)V#y- z3yS?a9sf71bNHW@Xz@SJ!xW4`Y>*fhH-Pkl%mA51v>TxIi#G3YJcMF5D4p$e{9n{+ z^FPkh6a|CCu-Feuiy$*VW)WpS)NYV_3!8U1{z0*Sr{n+HW%mD*!_C3|hP%PT6f6dk z!{P>z86dMjW)gG*)P9ZT9geq9><0OLyW{`dGAmTOVd3Cm3YKf$4zCkIeurU@Ss*j< z+7Gpxxp{}*uwyWnns+ArO_!|@D?-JmqL!|{JXy){Z+gZlMPypL%f2*-Jv z{(*|Y)q(V2GYe`5$S$z`P`g3ysYPp3f$NrjCM-5(c2Q8ptk?oiME5yuZM-3=hU zAT!X-h1vzO6J$So^A5*3=xSPaIsUJhX8S+E&kP=>F!STRO_wBvm~$isnlXSdhz$~h z$-`)nUXU3ev(U|l+6l5d9HUJID&yBX{7+ATmhrH(1){x7pC(=HT=LB0y}A7)UP8)AS^=9*`Lzvp{CT%!kq-J3)5yHScf)ByT?WW^if zV!|8eX^Mgqe9c%vaSpN<8H2>Ya%k#7X5$(!Tt{e|LXt$|6>oq zKji=a2jLI=|NlQ=hu{Ou|Nnz<1LOby3=ClWkAa~cg#R!w*n{v71_t>L3=I4r{D6Uh a9fS`sFffB~17iat17ib}cK|F4vKj!X^7qaF literal 0 HcmV?d00001 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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' + } +})