feat: protect write endpoints with JWT auth (story #08)
Add bcrypt user file auth, JWT middleware on all write routes, Pinia authStore with localStorage persistence, login view with redirect support, and v-if guards on all CRUD controls and admin nav link. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
function makeLocalStorageMock() {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v },
|
||||
removeItem: (k: string) => { delete store[k] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', makeLocalStorageMock())
|
||||
setActivePinia(createPinia())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('starts unauthenticated when localStorage is empty', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
|
||||
it('restores auth state from localStorage on init', () => {
|
||||
localStorage.setItem('auth_token', 'my-stored-token')
|
||||
const store = useAuthStore()
|
||||
expect(store.isLoggedIn).toBe(true)
|
||||
expect(store.token).toBe('my-stored-token')
|
||||
})
|
||||
|
||||
it('login sets token and isLoggedIn on success', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'new-jwt-token' }),
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login('admin', 'secret')
|
||||
|
||||
expect(store.isLoggedIn).toBe(true)
|
||||
expect(store.token).toBe('new-jwt-token')
|
||||
expect(localStorage.getItem('auth_token')).toBe('new-jwt-token')
|
||||
})
|
||||
|
||||
it('login throws on invalid credentials', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ok: false })
|
||||
|
||||
const store = useAuthStore()
|
||||
await expect(store.login('admin', 'wrong')).rejects.toThrow()
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('logout clears token and isLoggedIn', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'tok' }),
|
||||
})
|
||||
const store = useAuthStore()
|
||||
await store.login('admin', 'secret')
|
||||
store.logout()
|
||||
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.token).toBeNull()
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as apiLogin } from '../api/auth'
|
||||
|
||||
const TOKEN_KEY = 'auth_token'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||
const isLoggedIn = computed(() => token.value !== null)
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const resp = await apiLogin(username, password)
|
||||
token.value = resp.token
|
||||
localStorage.setItem(TOKEN_KEY, resp.token)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
return { token, isLoggedIn, login, logout }
|
||||
})
|
||||
Reference in New Issue
Block a user