feat: add login page with Playwright E2E tests
Add the frontend login page (LoginPage.vue) with email and password fields, Swedish UI strings, and integration with the backend login endpoint. Also sets up Playwright as the E2E testing framework with browser tests for both login and registration flows. Frontend login implementation: - Add LoginPage.vue with form validation, error handling, and link to registration page - Add login() API function in auth.ts - Add loginUser() method to authStore that stores JWT token - Add /logga-in route to Vue Router - Add 'Logga in' nav link to AppHeader alongside existing 'Registrera' - Add 10 unit tests for LoginPage component - Add 4 unit tests for loginUser auth store method - Add 1 route resolution test and 1 AppHeader link test Playwright E2E setup and tests: - Install @playwright/test and configure playwright.config.ts - Add npm scripts: test:e2e (local) and test:e2e:ci (Docker CI) - Exclude e2e/ directory from Vitest to prevent test runner conflicts - Add .gitignore entries for test-results/ and playwright-report/ - Add 5 E2E tests for login (navigation, invalid credentials, success redirect, navigation to register, input types) - Add 6 E2E tests for register (navigation, success redirect, validation errors for invalid email/short password/mismatched passwords, navigation to login)
This commit is contained in:
parent
3d4a6daee9
commit
491dc99c55
16 changed files with 647 additions and 3 deletions
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
|
|
@ -22,3 +22,7 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
|
|
|||
48
frontend/e2e/login.spec.ts
Normal file
48
frontend/e2e/login.spec.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Login page', () => {
|
||||
test('can navigate to login page', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows error for invalid credentials', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('user@example.com')
|
||||
await page.getByLabel('Lösenord').fill('wrongpassword')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
|
||||
await expect(page.getByText('Felaktig e-post eller lösenord')).toBeVisible()
|
||||
})
|
||||
|
||||
test('redirects to home after successful login', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('test@example.com')
|
||||
await page.getByLabel('Lösenord').fill('password123')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/')
|
||||
})
|
||||
|
||||
test('can navigate from login to register', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByRole('link', { name: 'Skapa konto' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/registrera')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Skapa konto' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('login form has correct input types', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await expect(page.getByLabel('E-postadress')).toHaveAttribute(
|
||||
'type',
|
||||
'email',
|
||||
)
|
||||
await expect(page.getByLabel('Lösenord')).toHaveAttribute(
|
||||
'type',
|
||||
'password',
|
||||
)
|
||||
})
|
||||
})
|
||||
56
frontend/e2e/register.spec.ts
Normal file
56
frontend/e2e/register.spec.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Register page', () => {
|
||||
test('can navigate to register page', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Skapa konto' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('registers a new user and redirects to home', async ({ page }) => {
|
||||
const uniqueEmail = `playwright-${Date.now()}@test.com`
|
||||
|
||||
await page.goto('/registrera')
|
||||
await page.getByLabel('E-postadress').fill(uniqueEmail)
|
||||
await page.getByLabel('Lösenord').first().fill('password123')
|
||||
await page.getByLabel('Bekräfta lösenord').fill('password123')
|
||||
await page.getByRole('button', { name: 'Skapa konto' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/')
|
||||
})
|
||||
|
||||
test('shows validation error for invalid email', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await page.getByLabel('E-postadress').fill('not-an-email')
|
||||
|
||||
await expect(page.getByText('Ange en giltig e-postadress')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows validation error for short password', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await page.getByLabel('Lösenord').first().fill('short')
|
||||
|
||||
await expect(
|
||||
page.getByText('Lösenordet måste vara minst 8 tecken'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows validation error for mismatched passwords', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await page.getByLabel('Lösenord').first().fill('password123')
|
||||
await page.getByLabel('Bekräfta lösenord').fill('different123')
|
||||
|
||||
await expect(page.getByText('Lösenorden matchar inte')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can navigate from register to login', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await page
|
||||
.getByRole('main')
|
||||
.getByRole('link', { name: 'Logga in' })
|
||||
.click()
|
||||
|
||||
await expect(page).toHaveURL('/logga-in')
|
||||
})
|
||||
})
|
||||
64
frontend/package-lock.json
generated
64
frontend/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
|
|
@ -670,6 +671,22 @@
|
|||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
|
|
@ -3717,6 +3734,53 @@
|
|||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
"lint": "eslint src/ --fix",
|
||||
"format": "prettier --write src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ci": "docker compose -f ../docker-compose.ci.yml up --build --abort-on-container-exit --exit-code-from playwright"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
|
|
@ -18,6 +20,7 @@
|
|||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
|
|
|
|||
29
frontend/playwright.config.ts
Normal file
29
frontend/playwright.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
const isCI = !!process.env.PLAYWRIGHT_BASE_URL
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||
headless: true,
|
||||
},
|
||||
...(isCI
|
||||
? {}
|
||||
: {
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
port: 3000,
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
}),
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -8,6 +8,16 @@ function createTestRouter() {
|
|||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{
|
||||
path: '/logga-in',
|
||||
name: 'login',
|
||||
component: { template: '<div>Login</div>' },
|
||||
},
|
||||
{
|
||||
path: '/registrera',
|
||||
name: 'register',
|
||||
component: { template: '<div>Register</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
|
@ -43,4 +53,15 @@ describe('AppHeader', () => {
|
|||
expect(registerLink).toBeTruthy()
|
||||
expect(registerLink?.text()).toBe('Registrera')
|
||||
})
|
||||
|
||||
it('has a link to login', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
|
||||
expect(loginLink).toBeTruthy()
|
||||
expect(loginLink?.text()).toBe('Logga in')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
148
frontend/src/__tests__/LoginPage.spec.ts
Normal file
148
frontend/src/__tests__/LoginPage.spec.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import LoginPage from '@/pages/LoginPage.vue'
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/logga-in', name: 'login', component: LoginPage },
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{
|
||||
path: '/registrera',
|
||||
name: 'register',
|
||||
component: { template: '<div>Register</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
const router = createTestRouter()
|
||||
const pinia = createPinia()
|
||||
router.push('/logga-in')
|
||||
return {
|
||||
router,
|
||||
wrapper: mount(LoginPage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: 'test-token' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders heading and subtitle', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.text()).toContain('Logga in')
|
||||
expect(wrapper.text()).toContain('Ange din e-postadress och ditt lösenord')
|
||||
})
|
||||
|
||||
it('renders email and password fields', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.find('#email').exists()).toBe(true)
|
||||
expect(wrapper.find('#password').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render confirm password field', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.find('#confirm-password').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disables submit when fields are empty', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables submit when both fields have values', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows loading text while submitting', async () => {
|
||||
globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {}))
|
||||
const { wrapper } = mountPage()
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
expect(wrapper.text()).toContain('Loggar in...')
|
||||
})
|
||||
|
||||
it('calls login API and redirects to home on success', async () => {
|
||||
const { wrapper, router } = mountPage()
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/auth/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('shows generic error on login failure', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||
)
|
||||
const { wrapper } = mountPage()
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('wrongpassword')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.text()).toContain('Felaktig e-post eller lösenord')
|
||||
})
|
||||
|
||||
it('does not leak specific error messages', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(500, { message: 'Internal server error' }),
|
||||
)
|
||||
const { wrapper } = mountPage()
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.text()).toContain('Felaktig e-post eller lösenord')
|
||||
expect(wrapper.text()).not.toContain('Internal server error')
|
||||
})
|
||||
|
||||
it('renders register link', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.text()).toContain('Har du inget konto?')
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,12 @@ describe('Router', () => {
|
|||
expect(router.currentRoute.value.name).toBe('register')
|
||||
})
|
||||
|
||||
it('resolves /logga-in to LoginPage', async () => {
|
||||
await router.push('/logga-in')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('does not crash on unknown route', async () => {
|
||||
await router.push('/nonexistent')
|
||||
await router.isReady()
|
||||
|
|
|
|||
|
|
@ -69,4 +69,64 @@ describe('authStore', () => {
|
|||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('sets token on loginUser success', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: 'login-token' }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('user@example.com', 'password123')
|
||||
|
||||
expect(store.token).toBe('login-token')
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(localStorage.getItem('auth_token')).toBe('login-token')
|
||||
})
|
||||
|
||||
it('rejects when login API fails', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await expect(
|
||||
store.loginUser('user@example.com', 'wrongpassword'),
|
||||
).rejects.toThrow('Felaktig e-post eller lösenord')
|
||||
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
|
||||
it('calls login endpoint with correct credentials', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: 'login-token' }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('user@example.com', 'password123')
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/auth/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call register endpoint on login', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: 'login-token' }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('user@example.com', 'password123')
|
||||
|
||||
const calls = vi.mocked(globalThis.fetch).mock.calls
|
||||
const registerCall = calls.find((call) => call[0] === '/api/auth/register')
|
||||
expect(registerCall).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,3 +13,10 @@ export function register(
|
|||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
}
|
||||
|
||||
export function login(email: string, password: string): Promise<AuthResponse> {
|
||||
return request<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { RouterLink } from 'vue-router'
|
|||
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
|
||||
<nav class="app-header__nav">
|
||||
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
|
||||
<RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
|
||||
<RouterLink to="/registrera" class="app-header__link"
|
||||
>Registrera</RouterLink
|
||||
>
|
||||
|
|
|
|||
185
frontend/src/pages/LoginPage.vue
Normal file
185
frontend/src/pages/LoginPage.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const isValid = computed(
|
||||
() => email.value.length > 0 && password.value.length > 0,
|
||||
)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid.value || submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await authStore.loginUser(email.value, password.value)
|
||||
await router.push('/')
|
||||
} catch {
|
||||
errorMessage.value = 'Felaktig e-post eller lösenord'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login">
|
||||
<h1 class="login__title">Logga in</h1>
|
||||
<p class="login__subtitle">
|
||||
Ange din e-postadress och ditt lösenord för att logga in.
|
||||
</p>
|
||||
|
||||
<form class="login__form" @submit.prevent="handleSubmit">
|
||||
<div class="login__field">
|
||||
<label for="email" class="login__label">E-postadress</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
class="login__input"
|
||||
placeholder="namn@exempel.se"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="login__field">
|
||||
<label for="password" class="login__label">Lösenord</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="login__input"
|
||||
placeholder="Ditt lösenord"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="login__api-error">{{ errorMessage }}</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="login__submit"
|
||||
:disabled="!isValid || submitting"
|
||||
>
|
||||
{{ submitting ? 'Loggar in...' : 'Logga in' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login__register-link">
|
||||
Har du inget konto?
|
||||
<RouterLink to="/registrera">Skapa konto</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
max-width: 28rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.login__title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.login__subtitle {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #718096;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.login__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.login__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.login__input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #cbd5e0;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login__input:focus {
|
||||
border-color: #4299e1;
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
|
||||
}
|
||||
|
||||
.login__api-error {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fed7d7;
|
||||
border-radius: 0.5rem;
|
||||
color: #c53030;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login__submit {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #4299e1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.login__submit:hover:not(:disabled) {
|
||||
background: #3182ce;
|
||||
}
|
||||
|
||||
.login__submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login__register-link {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.login__register-link a {
|
||||
color: #4299e1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login__register-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,6 +4,7 @@ import ComposePage from '@/pages/ComposePage.vue'
|
|||
import AboutPage from '@/pages/AboutPage.vue'
|
||||
import ContactPage from '@/pages/ContactPage.vue'
|
||||
import RegisterPage from '@/pages/RegisterPage.vue'
|
||||
import LoginPage from '@/pages/LoginPage.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -23,6 +24,11 @@ const router = createRouter({
|
|||
name: 'register',
|
||||
component: RegisterPage,
|
||||
},
|
||||
{
|
||||
path: '/logga-in',
|
||||
name: 'login',
|
||||
component: LoginPage,
|
||||
},
|
||||
{
|
||||
path: '/om',
|
||||
name: 'about',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { register } from '@/api/auth'
|
||||
import { register, login } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
||||
|
|
@ -22,9 +22,14 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
setToken(response.token)
|
||||
}
|
||||
|
||||
async function loginUser(email: string, password: string): Promise<void> {
|
||||
const response = await login(email, password)
|
||||
setToken(response.token)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken()
|
||||
}
|
||||
|
||||
return { token, isAuthenticated, registerUser, logout }
|
||||
return { token, isAuthenticated, registerUser, loginUser, logout }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@ export default defineConfig({
|
|||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/__tests__/setup.ts'],
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue