Add authentication check and logout functionality in app.vue
This commit is contained in:
parent
ee00a8018e
commit
733a1e9f47
9 changed files with 1294 additions and 1653 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -23,3 +23,4 @@ logs
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
.aider*
|
.aider*
|
||||||
|
migrate-to-atlas.js
|
||||||
|
|
|
||||||
32
app/app.vue
32
app/app.vue
|
|
@ -9,6 +9,15 @@
|
||||||
FABER FINANCES
|
FABER FINANCES
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
v-if="authenticated"
|
||||||
|
@click="logout"
|
||||||
|
class="text-white hover:text-gray-300 px-3 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -23,4 +32,27 @@ useSeoMeta({
|
||||||
titleTemplate: "%s - Faber Finances",
|
titleTemplate: "%s - Faber Finances",
|
||||||
description: "Personal finance and cash flow management system",
|
description: "Personal finance and cash flow management system",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authenticated = ref(false)
|
||||||
|
|
||||||
|
// Check authentication status
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { authenticated: isAuth } = await $fetch('/api/auth/check')
|
||||||
|
authenticated.value = isAuth
|
||||||
|
} catch (err) {
|
||||||
|
authenticated.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Logout function
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
authenticated.value = false
|
||||||
|
await navigateTo('/login')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mongodb": "^6.18.0",
|
"mongodb": "^6.18.0",
|
||||||
"nuxt": "^4.0.3",
|
"nuxt": "^4.0.3",
|
||||||
|
"unstorage": "^1.17.0",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
95
pages/login.vue
Normal file
95
pages/login.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Faber Finances
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Enter the shared password to continue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form class="mt-8 space-y-6" @submit.prevent="login">
|
||||||
|
<div class="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label for="password" class="sr-only">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Password"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-red-600 text-sm text-center">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span v-if="loading">Signing in...</span>
|
||||||
|
<span v-else>Sign in</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
if (!password.value) {
|
||||||
|
error.value = 'Password is required'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
password: password.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
await navigateTo('/')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.data?.message || 'Invalid password'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already authenticated
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { authenticated } = await $fetch('/api/auth/check')
|
||||||
|
if (authenticated) {
|
||||||
|
await navigateTo('/')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Not authenticated, stay on login page
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
server/api/auth/check.get.js
Normal file
21
server/api/auth/check.get.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const token = getCookie(event, 'auth-token')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return { authenticated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await useStorage('memory').getItem(`session:${token}`)
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { authenticated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session has expired
|
||||||
|
if (session.expiresAt && new Date() > new Date(session.expiresAt)) {
|
||||||
|
await useStorage('memory').removeItem(`session:${token}`)
|
||||||
|
return { authenticated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticated: true }
|
||||||
|
})
|
||||||
38
server/api/auth/login.post.js
Normal file
38
server/api/auth/login.post.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { password } = await readBody(event)
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Password is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const correctPassword = process.env.APP_PASSWORD
|
||||||
|
|
||||||
|
if (password !== correctPassword) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Invalid password'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
|
||||||
|
setCookie(event, 'auth-token', sessionToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 7 // 7 days
|
||||||
|
})
|
||||||
|
|
||||||
|
await useStorage('memory').setItem(`session:${sessionToken}`, {
|
||||||
|
authenticated: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + (60 * 60 * 24 * 7 * 1000)).toISOString() // 7 days
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
11
server/api/auth/logout.post.js
Normal file
11
server/api/auth/logout.post.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const token = getCookie(event, 'auth-token')
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
await useStorage('memory').removeItem(`session:${token}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCookie(event, 'auth-token')
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
43
server/middleware/auth.js
Normal file
43
server/middleware/auth.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// Skip auth check for login page and auth API routes
|
||||||
|
if (event.node.req.url?.startsWith('/api/auth/') ||
|
||||||
|
event.node.req.url === '/login' ||
|
||||||
|
event.node.req.url?.startsWith('/_nuxt/') ||
|
||||||
|
event.node.req.url?.startsWith('/__nuxt_devtools__/')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check auth for API routes and page requests
|
||||||
|
if (event.node.req.url?.startsWith('/api/') ||
|
||||||
|
!event.node.req.url?.includes('.')) {
|
||||||
|
|
||||||
|
const token = getCookie(event, 'auth-token')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (event.node.req.url?.startsWith('/api/')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Authentication required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Redirect to login for page requests
|
||||||
|
return sendRedirect(event, '/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await useStorage('memory').getItem(`session:${token}`)
|
||||||
|
|
||||||
|
if (!session || (session.expiresAt && new Date() > new Date(session.expiresAt))) {
|
||||||
|
if (session && session.expiresAt && new Date() > new Date(session.expiresAt)) {
|
||||||
|
await useStorage('memory').removeItem(`session:${token}`)
|
||||||
|
}
|
||||||
|
deleteCookie(event, 'auth-token')
|
||||||
|
if (event.node.req.url?.startsWith('/api/')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Session expired'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sendRedirect(event, '/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue