Skip to content

Commit e8459b5

Browse files
eprifticlaude
andcommitted
Add frontend login/register UI with auth navigation guard
New Pinia auth store manages JWT token in localStorage and sets axios default headers. LoginView provides login/register toggle form. Router guard redirects unauthenticated users to /login. Navbar shows user email and logout button when authenticated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e047561 commit e8459b5

4 files changed

Lines changed: 314 additions & 2 deletions

File tree

‎frontend/src/App.vue‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
<nav class="navbar">
44
<router-link to="/" class="brand">PredomicsApp</router-link>
55
<div class="nav-links">
6-
<router-link to="/projects">Projects</router-link>
6+
<router-link v-if="auth.isLoggedIn" to="/projects">Projects</router-link>
7+
</div>
8+
<div class="nav-right">
9+
<template v-if="auth.isLoggedIn">
10+
<span class="user-email">{{ auth.user?.email }}</span>
11+
<button class="btn-logout" @click="handleLogout">Logout</button>
12+
</template>
13+
<router-link v-else to="/login" class="btn-login">Login</router-link>
714
</div>
815
</nav>
916
<main class="container">
@@ -12,6 +19,26 @@
1219
</div>
1320
</template>
1421

22+
<script setup>
23+
import { onMounted } from 'vue'
24+
import { useRouter } from 'vue-router'
25+
import { useAuthStore } from './stores/auth'
26+
27+
const auth = useAuthStore()
28+
const router = useRouter()
29+
30+
onMounted(async () => {
31+
if (auth.token && !auth.user) {
32+
await auth.fetchUser()
33+
}
34+
})
35+
36+
function handleLogout() {
37+
auth.logout()
38+
router.push('/login')
39+
}
40+
</script>
41+
1542
<style>
1643
* {
1744
margin: 0;
@@ -56,6 +83,40 @@ body {
5683
color: white;
5784
}
5885
86+
.nav-right {
87+
margin-left: auto;
88+
display: flex;
89+
align-items: center;
90+
gap: 1rem;
91+
}
92+
93+
.user-email {
94+
color: #b0bec5;
95+
font-size: 0.85rem;
96+
}
97+
98+
.btn-logout {
99+
background: transparent;
100+
border: 1px solid #546e7a;
101+
color: #b0bec5;
102+
padding: 0.3rem 0.75rem;
103+
border-radius: 4px;
104+
font-size: 0.8rem;
105+
cursor: pointer;
106+
transition: all 0.2s;
107+
}
108+
109+
.btn-logout:hover {
110+
border-color: #e53935;
111+
color: #e53935;
112+
}
113+
114+
.btn-login {
115+
color: #4fc3f7;
116+
text-decoration: none;
117+
font-size: 0.9rem;
118+
}
119+
59120
.container {
60121
max-width: 1200px;
61122
margin: 2rem auto;

‎frontend/src/router.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { createRouter, createWebHistory } from 'vue-router'
22

33
const routes = [
4+
{
5+
path: '/login',
6+
name: 'Login',
7+
component: () => import('./views/LoginView.vue'),
8+
meta: { guest: true },
9+
},
410
{
511
path: '/',
612
name: 'Home',
@@ -28,7 +34,21 @@ const routes = [
2834
},
2935
]
3036

31-
export default createRouter({
37+
const router = createRouter({
3238
history: createWebHistory(),
3339
routes,
3440
})
41+
42+
router.beforeEach((to) => {
43+
const token = localStorage.getItem('token')
44+
// Redirect to login if not authenticated (except guest pages)
45+
if (!to.meta.guest && to.name !== 'Home' && !token) {
46+
return { name: 'Login' }
47+
}
48+
// Redirect away from login if already authenticated
49+
if (to.meta.guest && token) {
50+
return { name: 'Projects' }
51+
}
52+
})
53+
54+
export default router

‎frontend/src/stores/auth.js‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { defineStore } from 'pinia'
2+
import { ref, computed } from 'vue'
3+
import axios from 'axios'
4+
5+
export const useAuthStore = defineStore('auth', () => {
6+
const token = ref(localStorage.getItem('token') || '')
7+
const user = ref(null)
8+
9+
const isLoggedIn = computed(() => !!token.value)
10+
11+
// Set axios default header whenever token changes
12+
function setToken(t) {
13+
token.value = t
14+
if (t) {
15+
localStorage.setItem('token', t)
16+
axios.defaults.headers.common['Authorization'] = `Bearer ${t}`
17+
} else {
18+
localStorage.removeItem('token')
19+
delete axios.defaults.headers.common['Authorization']
20+
}
21+
}
22+
23+
// Restore token on app load
24+
if (token.value) {
25+
axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
26+
}
27+
28+
async function login(email, password) {
29+
const { data } = await axios.post('/api/auth/login', { email, password })
30+
setToken(data.access_token)
31+
await fetchUser()
32+
}
33+
34+
async function register(email, password, full_name) {
35+
await axios.post('/api/auth/register', { email, password, full_name })
36+
await login(email, password)
37+
}
38+
39+
async function fetchUser() {
40+
try {
41+
const { data } = await axios.get('/api/auth/me')
42+
user.value = data
43+
} catch {
44+
logout()
45+
}
46+
}
47+
48+
function logout() {
49+
setToken('')
50+
user.value = null
51+
}
52+
53+
return { token, user, isLoggedIn, login, register, fetchUser, logout }
54+
})

‎frontend/src/views/LoginView.vue‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<template>
2+
<div class="login-page">
3+
<div class="login-card">
4+
<h1 class="login-title">PredomicsApp</h1>
5+
<p class="login-subtitle">Microbiome-based classification</p>
6+
7+
<div class="tab-switcher">
8+
<button :class="{ active: mode === 'login' }" @click="mode = 'login'">Login</button>
9+
<button :class="{ active: mode === 'register' }" @click="mode = 'register'">Register</button>
10+
</div>
11+
12+
<form @submit.prevent="submit">
13+
<div v-if="mode === 'register'" class="field">
14+
<label>Full Name</label>
15+
<input v-model="fullName" type="text" placeholder="Your name" />
16+
</div>
17+
<div class="field">
18+
<label>Email</label>
19+
<input v-model="email" type="email" placeholder="email@example.com" required />
20+
</div>
21+
<div class="field">
22+
<label>Password</label>
23+
<input v-model="password" type="password" placeholder="Password" required />
24+
</div>
25+
<p v-if="error" class="error">{{ error }}</p>
26+
<button type="submit" class="btn-primary" :disabled="loading">
27+
{{ loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account') }}
28+
</button>
29+
</form>
30+
</div>
31+
</div>
32+
</template>
33+
34+
<script setup>
35+
import { ref } from 'vue'
36+
import { useRouter } from 'vue-router'
37+
import { useAuthStore } from '../stores/auth'
38+
39+
const auth = useAuthStore()
40+
const router = useRouter()
41+
42+
const mode = ref('login')
43+
const email = ref('')
44+
const password = ref('')
45+
const fullName = ref('')
46+
const error = ref('')
47+
const loading = ref(false)
48+
49+
async function submit() {
50+
error.value = ''
51+
loading.value = true
52+
try {
53+
if (mode.value === 'login') {
54+
await auth.login(email.value, password.value)
55+
} else {
56+
await auth.register(email.value, password.value, fullName.value)
57+
}
58+
router.push('/projects')
59+
} catch (e) {
60+
const detail = e.response?.data?.detail
61+
error.value = detail || 'Something went wrong. Please try again.'
62+
} finally {
63+
loading.value = false
64+
}
65+
}
66+
</script>
67+
68+
<style scoped>
69+
.login-page {
70+
display: flex;
71+
justify-content: center;
72+
align-items: center;
73+
min-height: 80vh;
74+
}
75+
76+
.login-card {
77+
background: white;
78+
border-radius: 12px;
79+
padding: 2.5rem;
80+
width: 100%;
81+
max-width: 400px;
82+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
83+
}
84+
85+
.login-title {
86+
text-align: center;
87+
color: #1a1a2e;
88+
font-size: 1.75rem;
89+
margin-bottom: 0.25rem;
90+
}
91+
92+
.login-subtitle {
93+
text-align: center;
94+
color: #78909c;
95+
font-size: 0.9rem;
96+
margin-bottom: 1.5rem;
97+
}
98+
99+
.tab-switcher {
100+
display: flex;
101+
gap: 0;
102+
margin-bottom: 1.5rem;
103+
border-radius: 8px;
104+
overflow: hidden;
105+
border: 1px solid #e0e0e0;
106+
}
107+
108+
.tab-switcher button {
109+
flex: 1;
110+
padding: 0.6rem;
111+
border: none;
112+
background: #f5f5f5;
113+
color: #78909c;
114+
font-size: 0.9rem;
115+
cursor: pointer;
116+
transition: all 0.2s;
117+
}
118+
119+
.tab-switcher button.active {
120+
background: #1a1a2e;
121+
color: white;
122+
}
123+
124+
.field {
125+
margin-bottom: 1rem;
126+
}
127+
128+
.field label {
129+
display: block;
130+
font-size: 0.85rem;
131+
font-weight: 500;
132+
color: #546e7a;
133+
margin-bottom: 0.3rem;
134+
}
135+
136+
.field input {
137+
width: 100%;
138+
padding: 0.6rem 0.75rem;
139+
border: 1px solid #e0e0e0;
140+
border-radius: 6px;
141+
font-size: 0.9rem;
142+
outline: none;
143+
transition: border-color 0.2s;
144+
}
145+
146+
.field input:focus {
147+
border-color: #4fc3f7;
148+
}
149+
150+
.error {
151+
color: #e53935;
152+
font-size: 0.85rem;
153+
margin-bottom: 0.75rem;
154+
}
155+
156+
.btn-primary {
157+
width: 100%;
158+
padding: 0.7rem;
159+
background: #1a1a2e;
160+
color: white;
161+
border: none;
162+
border-radius: 6px;
163+
font-size: 0.95rem;
164+
cursor: pointer;
165+
transition: background 0.2s;
166+
margin-top: 0.5rem;
167+
}
168+
169+
.btn-primary:hover {
170+
background: #2d2d4e;
171+
}
172+
173+
.btn-primary:disabled {
174+
opacity: 0.6;
175+
cursor: not-allowed;
176+
}
177+
</style>

0 commit comments

Comments
 (0)