Rewritten frontend with Vue
This commit is contained in:
		
							
								
								
									
										30
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										29
									
								
								frontend/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | ``` | ||||||
							
								
								
									
										
											BIN
										
									
								
								frontend/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										13
									
								
								frontend/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang=""> | ||||||
|  |   <head> | ||||||
|  |     <meta charset="UTF-8"> | ||||||
|  |     <link rel="icon" href="/favicon.ico"> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|  |     <title>Vite App</title> | ||||||
|  |   </head> | ||||||
|  |   <body> | ||||||
|  |     <div id="app"></div> | ||||||
|  |     <script type="module" src="/src/main.js"></script> | ||||||
|  |   </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										8
									
								
								frontend/jsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/jsconfig.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | { | ||||||
|  |   "compilerOptions": { | ||||||
|  |     "paths": { | ||||||
|  |       "@/*": ["./src/*"] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "exclude": ["node_modules", "dist"] | ||||||
|  | } | ||||||
							
								
								
									
										22
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								frontend/public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.2 KiB | 
							
								
								
									
										46
									
								
								frontend/src/App.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/src/App.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <StatusMessage /> | ||||||
|  |     <LoginView v-if="store.currentView === 'login'" /> | ||||||
|  |     <RegisterView v-if="store.currentView === 'register'" /> | ||||||
|  |     <ProfileView v-if="store.currentView === 'profile'" /> | ||||||
|  |     <DeviceLinkView v-if="store.currentView === 'device-link'" /> | ||||||
|  |     <AddDeviceCredentialView v-if="store.currentView === 'add-device-credential'" /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { onMounted, ref } from 'vue' | ||||||
|  | import { useAuthStore } from '@/stores/auth' | ||||||
|  | import StatusMessage from '@/components/StatusMessage.vue' | ||||||
|  | import LoginView from '@/components/LoginView.vue' | ||||||
|  | import RegisterView from '@/components/RegisterView.vue' | ||||||
|  | import ProfileView from '@/components/ProfileView.vue' | ||||||
|  | import DeviceLinkView from '@/components/DeviceLinkView.vue' | ||||||
|  | import AddDeviceCredentialView from '@/components/AddDeviceCredentialView.vue' | ||||||
|  | import { getCookie } from './utils/helpers' | ||||||
|  |  | ||||||
|  | const store = useAuthStore() | ||||||
|  | let isLoggedIn | ||||||
|  |  | ||||||
|  | onMounted(async () => { | ||||||
|  |   if (getCookie('auth-token')) { | ||||||
|  |     store.currentView = 'add-device-credential' | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |   isLoggedIn = await store.validateStoredToken() | ||||||
|  |   if (isLoggedIn) { | ||||||
|  |     // User is logged in, load their data and go to profile | ||||||
|  |     try { | ||||||
|  |       await store.loadUserInfo() | ||||||
|  |       store.currentView = 'profile' | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error('Failed to load user info:', error) | ||||||
|  |       store.currentView = 'login' | ||||||
|  |     } | ||||||
|  |   } else { | ||||||
|  |     // User is not logged in, show login | ||||||
|  |     store.currentView = 'login' | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  | </script> | ||||||
							
								
								
									
										482
									
								
								frontend/src/assets/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										482
									
								
								frontend/src/assets/style.css
									
									
									
									
									
										Normal file
									
								
							| @@ -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%; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										55
									
								
								frontend/src/components/AddDeviceCredentialView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								frontend/src/components/AddDeviceCredentialView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="container"> | ||||||
|  |     <div class="view active"> | ||||||
|  |       <h1>🔑 Add Device Credential</h1> | ||||||
|  |       <button | ||||||
|  |         class="btn-primary" | ||||||
|  |         :disabled="authStore.isLoading" | ||||||
|  |         @click="register" | ||||||
|  |       > | ||||||
|  |         {{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }} | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { useAuthStore } from '@/stores/auth' | ||||||
|  | import { registerWithToken } from '@/utils/passkey' | ||||||
|  | import { ref, onMounted } from 'vue' | ||||||
|  | import { getCookie } from '@/utils/helpers' | ||||||
|  |  | ||||||
|  | const authStore = useAuthStore() | ||||||
|  | const token = ref(null) | ||||||
|  |  | ||||||
|  | // Check existing session on app load | ||||||
|  | onMounted(() => { | ||||||
|  |   // Check for 'auth-token' cookie | ||||||
|  |   token.value = getCookie('auth-token') | ||||||
|  |   if (!token.value) { | ||||||
|  |     authStore.showMessage('No registration token cookie found.', 'error') | ||||||
|  |     authStore.currentView = 'login' | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |   // Delete the cookie | ||||||
|  |   document.cookie = 'auth-token=; Max-Age=0; path=/' | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | function register() { | ||||||
|  |   authStore.isLoading = true | ||||||
|  |   authStore.showMessage('Starting registration...', 'info') | ||||||
|  |   registerWithToken(token.value).finally(() => { | ||||||
|  |     authStore.isLoading = false | ||||||
|  |   }).then(() => { | ||||||
|  |     authStore.showMessage('Passkey registered successfully!', 'success', 2000) | ||||||
|  |     authStore.currentView = 'profile' | ||||||
|  |   }).catch((error) => { | ||||||
|  |     console.error('Registration error:', error) | ||||||
|  |     if (error.name === "NotAllowedError") { | ||||||
|  |       authStore.showMessage('Registration cancelled', 'error') | ||||||
|  |     } else { | ||||||
|  |       authStore.showMessage(`Registration failed: ${error.message}`, 'error') | ||||||
|  |     } | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  | </script> | ||||||
							
								
								
									
										69
									
								
								frontend/src/components/DeviceLinkView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								frontend/src/components/DeviceLinkView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="container"> | ||||||
|  |     <div class="view active"> | ||||||
|  |       <h1>📱 Add Device</h1> | ||||||
|  |  | ||||||
|  |       <div class="device-link-section"> | ||||||
|  |         <h2>Device Addition Link</h2> | ||||||
|  |         <div class="qr-container"> | ||||||
|  |           <canvas id="qrCode" class="qr-code"></canvas> | ||||||
|  |           <p v-if="deviceLink.url"> | ||||||
|  |             <a :href="deviceLink.url" id="deviceLinkText" @click="copyLink"> | ||||||
|  |               {{ deviceLink.url.replace(/^[^:]+:\/\//, '') }} | ||||||
|  |             </a> | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <p> | ||||||
|  |           <strong>Scan and visit the URL on another device.</strong><br> | ||||||
|  |           <small>⚠️ Expires in 24 hours and can only be used once.</small> | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <button @click="authStore.currentView = 'profile'" class="btn-secondary"> | ||||||
|  |         Back to Profile | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { ref, onMounted } from 'vue' | ||||||
|  | import { useAuthStore } from '@/stores/auth' | ||||||
|  | import QRCode from 'qrcode/lib/browser' | ||||||
|  |  | ||||||
|  | const authStore = useAuthStore() | ||||||
|  | const deviceLink = ref({ url: '', token: '' }) | ||||||
|  |  | ||||||
|  | const copyLink = async (event) => { | ||||||
|  |   event.preventDefault() | ||||||
|  |   if (deviceLink.value.url) { | ||||||
|  |     await navigator.clipboard.writeText(deviceLink.value.url) | ||||||
|  |     authStore.showMessage('Link copied to clipboard!') | ||||||
|  |     authStore.currentView = 'profile' | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | onMounted(async () => { | ||||||
|  |   try { | ||||||
|  |     const response = await fetch('/auth/create-device-link', { method: 'POST' }) | ||||||
|  |     const result = await response.json() | ||||||
|  |     if (result.error) throw new Error(result.error) | ||||||
|  |  | ||||||
|  |     deviceLink.value = { | ||||||
|  |       url: result.addition_link, | ||||||
|  |       token: result.token | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Generate QR code | ||||||
|  |     const qrCodeElement = document.getElementById('qrCode') | ||||||
|  |     if (qrCodeElement) { | ||||||
|  |       QRCode.toCanvas(qrCodeElement, deviceLink.value.url, error => { | ||||||
|  |         if (error) console.error('Failed to generate QR code:', error) | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Failed to fetch device link:', error) | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  | </script> | ||||||
							
								
								
									
										39
									
								
								frontend/src/components/LoginView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								frontend/src/components/LoginView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="container"> | ||||||
|  |     <div class="view active"> | ||||||
|  |       <h1>🔐 Passkey Login</h1> | ||||||
|  |       <form @submit.prevent="handleLogin"> | ||||||
|  |         <button | ||||||
|  |           type="submit" | ||||||
|  |           class="btn-primary" | ||||||
|  |           :disabled="authStore.isLoading" | ||||||
|  |         > | ||||||
|  |           {{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }} | ||||||
|  |         </button> | ||||||
|  |       </form> | ||||||
|  |       <p class="toggle-link"> | ||||||
|  |         <a href="#" @click.prevent="authStore.currentView = 'register'"> | ||||||
|  |           Don't have an account? Register here | ||||||
|  |         </a> | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { useAuthStore } from '@/stores/auth' | ||||||
|  |  | ||||||
|  | const authStore = useAuthStore() | ||||||
|  |  | ||||||
|  | const handleLogin = async () => { | ||||||
|  |   try { | ||||||
|  |     console.log('Login button clicked') | ||||||
|  |     authStore.showMessage('Starting authentication...', 'info') | ||||||
|  |     await authStore.authenticate() | ||||||
|  |     authStore.showMessage('Authentication successful!', 'success', 2000) | ||||||
|  |     authStore.currentView = 'profile' | ||||||
|  |   } catch (error) { | ||||||
|  |     authStore.showMessage(`Authentication failed: ${error.message}`, 'error') | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
							
								
								
									
										185
									
								
								frontend/src/components/ProfileView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								frontend/src/components/ProfileView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="container"> | ||||||
|  |     <div class="view active"> | ||||||
|  |       <h1>👋 Welcome!</h1> | ||||||
|  |       <div v-if="authStore.currentUser" class="user-info"> | ||||||
|  |         <h3>👤 {{ authStore.currentUser.user_name }}</h3> | ||||||
|  |         <span><strong>Visits:</strong></span> | ||||||
|  |         <span>{{ authStore.currentUser.visits || 0 }}</span> | ||||||
|  |         <span><strong>Registered:</strong></span> | ||||||
|  |         <span>{{ formatDate(authStore.currentUser.created_at) }}</span> | ||||||
|  |         <span><strong>Last seen:</strong></span> | ||||||
|  |         <span>{{ formatDate(authStore.currentUser.last_seen) }}</span> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <h2>Your Passkeys</h2> | ||||||
|  |       <div class="credential-list"> | ||||||
|  |         <div v-if="authStore.isLoading"> | ||||||
|  |           <p>Loading credentials...</p> | ||||||
|  |         </div> | ||||||
|  |         <div v-else-if="userCredentialsData.credentials.length === 0"> | ||||||
|  |           <p>No passkeys found.</p> | ||||||
|  |         </div> | ||||||
|  |         <div v-else> | ||||||
|  |           <div | ||||||
|  |             v-for="credential in userCredentialsData.credentials" | ||||||
|  |             :key="credential.credential_id" | ||||||
|  |             :class="['credential-item', { 'current-session': credential.is_current_session }]" | ||||||
|  |           > | ||||||
|  |             <div class="credential-header"> | ||||||
|  |               <div class="credential-icon"> | ||||||
|  |                 <img | ||||||
|  |                   v-if="getCredentialAuthIcon(credential)" | ||||||
|  |                   :src="getCredentialAuthIcon(credential)" | ||||||
|  |                   :alt="getCredentialAuthName(credential)" | ||||||
|  |                   class="auth-icon" | ||||||
|  |                   width="32" | ||||||
|  |                   height="32" | ||||||
|  |                 > | ||||||
|  |                 <span v-else class="auth-emoji">🔑</span> | ||||||
|  |               </div> | ||||||
|  |               <div class="credential-info"> | ||||||
|  |                 <h4>{{ getCredentialAuthName(credential) }}</h4> | ||||||
|  |               </div> | ||||||
|  |               <div class="credential-dates"> | ||||||
|  |                 <span class="date-label">Created:</span> | ||||||
|  |                 <span class="date-value">{{ formatDate(credential.created_at) }}</span> | ||||||
|  |                 <span class="date-label">Last used:</span> | ||||||
|  |                 <span class="date-value">{{ formatDate(credential.last_used) }}</span> | ||||||
|  |               </div> | ||||||
|  |               <div class="credential-actions"> | ||||||
|  |                 <button | ||||||
|  |                   @click="deleteCredential(credential.credential_id)" | ||||||
|  |                   class="btn-delete-credential" | ||||||
|  |                   :disabled="credential.is_current_session" | ||||||
|  |                   :title="credential.is_current_session ? 'Cannot delete current session credential' : ''" | ||||||
|  |                 > | ||||||
|  |                   🗑️ | ||||||
|  |                 </button> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div class="button-group" style="display: flex; gap: 10px;"> | ||||||
|  |         <button @click="addNewCredential" class="btn-primary"> | ||||||
|  |           Add New Passkey | ||||||
|  |         </button> | ||||||
|  |         <button @click="authStore.currentView = 'device-link'" class="btn-primary"> | ||||||
|  |           Add Another Device | ||||||
|  |         </button> | ||||||
|  |       </div> | ||||||
|  |       <button @click="logout" class="btn-danger" style="width: 100%;"> | ||||||
|  |         Logout | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { ref, onMounted, onUnmounted } from 'vue' | ||||||
|  | import { useAuthStore } from '@/stores/auth' | ||||||
|  | import { formatDate } from '@/utils/helpers' | ||||||
|  | import { registerCredential } from '@/utils/passkey' | ||||||
|  |  | ||||||
|  | const authStore = useAuthStore() | ||||||
|  | const currentCredentials = ref([]) | ||||||
|  | const userCredentialsData = ref({ credentials: [], aaguid_info: {} }) | ||||||
|  | const updateInterval = ref(null) | ||||||
|  |  | ||||||
|  | onMounted(async () => { | ||||||
|  |   try { | ||||||
|  |     await authStore.loadUserInfo() | ||||||
|  |     currentCredentials.value = await authStore.loadCredentials() | ||||||
|  |   } catch (error) { | ||||||
|  |     authStore.showMessage(`Failed to load user info: ${error.message}`, 'error') | ||||||
|  |     authStore.currentView = 'login' | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Fetch user credentials from the server | ||||||
|  |   try { | ||||||
|  |     const response = await fetch('/auth/user-credentials') | ||||||
|  |     const result = await response.json() | ||||||
|  |     console.log('Fetch Response:', result) // Log the entire response | ||||||
|  |     if (result.error) throw new Error(result.error) | ||||||
|  |  | ||||||
|  |     Object.assign(userCredentialsData.value, result) // Store the entire response | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Failed to fetch user credentials:', error) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   updateInterval.value = setInterval(() => { | ||||||
|  |     // Trigger Vue reactivity to update formatDate fields | ||||||
|  |     authStore.currentUser = { ...authStore.currentUser } | ||||||
|  |     userCredentialsData.value.credentials = [...userCredentialsData.value.credentials] | ||||||
|  |   }, 60000) // Update every minute | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | onUnmounted(() => { | ||||||
|  |   if (updateInterval.value) { | ||||||
|  |     clearInterval(updateInterval.value) | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const getCredentialAuthName = (credential) => { | ||||||
|  |   const authInfo = userCredentialsData.value.aaguid_info[credential.aaguid] | ||||||
|  |   return authInfo ? authInfo.name : 'Unknown Authenticator' | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const getCredentialAuthIcon = (credential) => { | ||||||
|  |   const authInfo = userCredentialsData.value.aaguid_info[credential.aaguid] | ||||||
|  |   if (!authInfo) return null | ||||||
|  |  | ||||||
|  |   const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches | ||||||
|  |   const iconKey = isDarkMode ? 'icon_dark' : 'icon_light' | ||||||
|  |   return authInfo[iconKey] || null | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const addNewCredential = async () => { | ||||||
|  |   try { | ||||||
|  |     authStore.isLoading = true | ||||||
|  |     authStore.showMessage('Adding new passkey...', 'info') | ||||||
|  |     const result = await registerCredential() | ||||||
|  |     currentCredentials.value = await authStore.loadCredentials() | ||||||
|  |     authStore.showMessage('New passkey added successfully!', 'success', 3000) | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Failed to add new passkey:', error) | ||||||
|  |     authStore.showMessage(`Failed to add passkey: ${error.message}`, 'error') | ||||||
|  |   } finally { | ||||||
|  |     authStore.isLoading = false | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const deleteCredential = async (credentialId) => { | ||||||
|  |   if (!confirm('Are you sure you want to delete this passkey?')) return | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     await authStore.deleteCredential(credentialId) | ||||||
|  |     currentCredentials.value = await authStore.loadCredentials() | ||||||
|  |     authStore.showMessage('Passkey deleted successfully!', 'success', 3000) | ||||||
|  |   } catch (error) { | ||||||
|  |     authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const logout = async () => { | ||||||
|  |   await authStore.logout() | ||||||
|  |   authStore.currentView = 'login' | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .user-info { | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: auto 1fr; | ||||||
|  |   gap: 10px; | ||||||
|  | } | ||||||
|  | .user-info h3 { | ||||||
|  |   grid-column: span 2; | ||||||
|  | } | ||||||
|  | .user-info span { | ||||||
|  |   text-align: left; | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										57
									
								
								frontend/src/components/RegisterView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								frontend/src/components/RegisterView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="container"> | ||||||
|  |     <div class="view active"> | ||||||
|  |       <h1>🔐 Create Account</h1> | ||||||
|  |       <form @submit.prevent="handleRegister"> | ||||||
|  |         <input | ||||||
|  |           type="text" | ||||||
|  |           v-model="username" | ||||||
|  |           placeholder="Enter username" | ||||||
|  |           required | ||||||
|  |           :disabled="authStore.isLoading" | ||||||
|  |         > | ||||||
|  |         <button | ||||||
|  |           type="submit" | ||||||
|  |           class="btn-primary" | ||||||
|  |           :disabled="authStore.isLoading || !username.trim()" | ||||||
|  |         > | ||||||
|  |           {{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }} | ||||||
|  |         </button> | ||||||
|  |       </form> | ||||||
|  |       <p class="toggle-link"> | ||||||
|  |         <a href="#" @click.prevent="authStore.currentView = 'login'"> | ||||||
|  |           Already have an account? Login here | ||||||
|  |         </a> | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { ref } from 'vue' | ||||||
|  | import { useAuthStore } from '@/stores/auth' | ||||||
|  |  | ||||||
|  | const authStore = useAuthStore() | ||||||
|  | const user_name = ref('') | ||||||
|  |  | ||||||
|  | const handleRegister = async () => { | ||||||
|  |   if (!user_name.value.trim()) return | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     authStore.showMessage('Starting registration...', 'info') | ||||||
|  |     await authStore.register(user_name.value.trim()) | ||||||
|  |     authStore.showMessage('Passkey registered successfully!', 'success', 2000) | ||||||
|  |  | ||||||
|  |     setTimeout(() => { | ||||||
|  |       authStore.currentView = 'profile' | ||||||
|  |     }, 1500) | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Registration error:', error) | ||||||
|  |     if (error.name === "NotAllowedError") { | ||||||
|  |       authStore.showMessage('Registration cancelled', 'error') | ||||||
|  |     } else { | ||||||
|  |       authStore.showMessage(`Registration failed: ${error.message}`, 'error') | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
							
								
								
									
										13
									
								
								frontend/src/components/StatusMessage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/StatusMessage.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | <template> | ||||||
|  |   <div v-if="authStore.status.show" class="global-status" style="display: block;"> | ||||||
|  |     <div :class="['status', authStore.status.type]"> | ||||||
|  |       {{ authStore.status.message }} | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { useAuthStore } from '@/stores/auth' | ||||||
|  |  | ||||||
|  | const authStore = useAuthStore() | ||||||
|  | </script> | ||||||
							
								
								
									
										11
									
								
								frontend/src/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/main.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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') | ||||||
							
								
								
									
										161
									
								
								frontend/src/stores/auth.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								frontend/src/stores/auth.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |   } | ||||||
|  | }) | ||||||
							
								
								
									
										83
									
								
								frontend/src/utils/awaitable-websocket.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								frontend/src/utils/awaitable-websocket.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  |   }) | ||||||
|  | } | ||||||
							
								
								
									
										24
									
								
								frontend/src/utils/helpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/utils/helpers.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||||
|  | } | ||||||
							
								
								
									
										38
									
								
								frontend/src/utils/passkey.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								frontend/src/utils/passkey.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
							
								
								
									
										33
									
								
								frontend/vite.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								frontend/vite.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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' | ||||||
|  |   } | ||||||
|  | }) | ||||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko