feat(frontend): auth store with user object + isAdmin, router with admin guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-05-16 09:26:25 +00:00
parent fa39d6b55e
commit 746c3bd286
6 changed files with 327 additions and 52 deletions

View File

@ -6,60 +6,38 @@ import SimulationRunView from '../views/SimulationRunView.vue'
import ReportView from '../views/ReportView.vue'
import InteractionView from '../views/InteractionView.vue'
import LoginView from '../views/LoginView.vue'
import authState from '../store/auth'
import ForgotPasswordView from '../views/ForgotPasswordView.vue'
import ResetPasswordView from '../views/ResetPasswordView.vue'
import SetPasswordView from '../views/SetPasswordView.vue'
import AdminView from '../views/AdminView.vue'
import authState, { isAdmin } from '../store/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: LoginView,
meta: { public: true }
},
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/process/:projectId',
name: 'Process',
component: Process,
props: true
},
{
path: '/simulation/:simulationId',
name: 'Simulation',
component: SimulationView,
props: true
},
{
path: '/simulation/:simulationId/start',
name: 'SimulationRun',
component: SimulationRunView,
props: true
},
{
path: '/report/:reportId',
name: 'Report',
component: ReportView,
props: true
},
{
path: '/interaction/:reportId',
name: 'Interaction',
component: InteractionView,
props: true
}
// Públiques
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
{ path: '/forgot-password', name: 'ForgotPassword', component: ForgotPasswordView, meta: { public: true } },
{ path: '/reset-password/:token', name: 'ResetPassword', component: ResetPasswordView, meta: { public: true }, props: true },
{ path: '/accept-invite/:token', name: 'AcceptInvite', component: SetPasswordView, meta: { public: true }, props: true },
// Privades
{ path: '/', name: 'Home', component: Home },
{ path: '/process/:projectId', name: 'Process', component: Process, props: true },
{ path: '/simulation/:simulationId', name: 'Simulation', component: SimulationView, props: true },
{ path: '/simulation/:simulationId/start', name: 'SimulationRun', component: SimulationRunView, props: true },
{ path: '/report/:reportId', name: 'Report', component: ReportView, props: true },
{ path: '/interaction/:reportId', name: 'Interaction', component: InteractionView, props: true },
// Admin only
{ path: '/admin', redirect: '/admin/users' },
{ path: '/admin/:tab', name: 'Admin', component: AdminView, props: true, meta: { requiresAdmin: true } },
]
const router = createRouter({
history: createWebHistory(),
routes
})
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, from, next) => {
if (to.meta?.public) return next()
if (!authState.isAuthenticated) return next({ name: 'Login', query: { redirect: to.fullPath } })
if (to.meta?.requiresAdmin && !isAdmin.value) return next({ name: 'Home' })
if (to.name === 'Login') return next({ name: 'Home' })
next()
})

View File

@ -1,26 +1,41 @@
import { reactive } from 'vue'
import { reactive, computed } from 'vue'
const AUTH_KEY = 'mirofish_token'
const USER_KEY = 'mirofish_user'
const state = reactive({
token: localStorage.getItem(AUTH_KEY) || null,
isAuthenticated: !!localStorage.getItem(AUTH_KEY)
user: JSON.parse(localStorage.getItem(USER_KEY) || 'null'),
get isAuthenticated() { return !!this.token }
})
export function setToken(token) {
export const isAdmin = computed(() => state.user?.role === 'admin')
export function setAuth(token, user) {
state.token = token
state.isAuthenticated = true
state.user = user
localStorage.setItem(AUTH_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
export function clearToken() {
export function clearAuth() {
state.token = null
state.isAuthenticated = false
state.user = null
localStorage.removeItem(AUTH_KEY)
localStorage.removeItem(USER_KEY)
}
export function getToken() {
return state.token
}
// Compatibilitat enrere (LoginView usa setToken)
export function setToken(token) {
setAuth(token, state.user)
}
export function clearToken() {
clearAuth()
}
export default state

View File

@ -0,0 +1,9 @@
<template>
<div class="admin-placeholder">
<!-- AdminView pending T15 implementation -->
</div>
</template>
<script setup>
defineProps({ tab: String })
</script>

View File

@ -0,0 +1,76 @@
<template>
<div class="auth-container">
<nav class="navbar"><div class="nav-brand">MIROFISH</div></nav>
<main class="auth-main">
<div class="auth-card">
<div class="card-header">
<span class="tag">AUTH</span>
<h1 class="title">{{ $t('forgotPassword.title') }}</h1>
<p class="subtitle">{{ $t('forgotPassword.subtitle') }}</p>
</div>
<div v-if="sent" class="success-msg">{{ $t('forgotPassword.sent') }}</div>
<form v-else class="auth-form" @submit.prevent="handleSubmit">
<div class="field">
<label class="field-label">{{ $t('login.email') }}</label>
<input v-model="email" type="email" class="field-input"
:disabled="loading" :placeholder="$t('login.emailPlaceholder')" />
</div>
<div v-if="error" class="error-msg">{{ error }}</div>
<button type="submit" class="submit-btn" :disabled="loading || !email.trim()">
<span v-if="loading">{{ $t('common.loading') }}</span>
<span v-else>{{ $t('forgotPassword.submit') }} </span>
</button>
<router-link to="/login" class="back-link"> {{ $t('common.back') }}</router-link>
</form>
</div>
</main>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import service from '../api/index'
const { t } = useI18n()
const email = ref('')
const loading = ref(false)
const sent = ref(false)
const error = ref('')
async function handleSubmit() {
loading.value = true; error.value = ''
try {
await service.post('/api/auth/forgot-password', { email: email.value })
sent.value = true
} catch {
error.value = t('common.unknownError')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.auth-container { min-height: 100vh; background: #fff; font-family: 'Space Grotesk', system-ui, sans-serif; color: #000; display: flex; flex-direction: column; }
.navbar { height: 60px; background: #000; color: #fff; display: flex; align-items: center; padding: 0 40px; }
.nav-brand { font-family: 'JetBrains Mono', monospace; font-weight: 800; letter-spacing: 1px; font-size: 1.2rem; }
.auth-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
.auth-card { width: 100%; max-width: 400px; border: 1px solid #e5e5e5; padding: 48px 40px; }
.card-header { margin-bottom: 32px; }
.tag { display: inline-block; background: #ff4500; color: #fff; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 700; padding: 2px 8px; letter-spacing: 1px; margin-bottom: 16px; }
.title { font-size: 1.8rem; font-weight: 500; margin-bottom: 8px; }
.subtitle { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #666; }
.auth-form { display: flex; flex-direction: column; gap: 20px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.field-label { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; }
.field-input { border: 1px solid #e5e5e5; background: #fafafa; padding: 12px 16px; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; outline: none; width: 100%; box-sizing: border-box; }
.field-input:focus { border-color: #000; background: #fff; }
.error-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: #ff4500; border-left: 3px solid #ff4500; padding-left: 12px; }
.success-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #22c55e; border-left: 3px solid #22c55e; padding-left: 12px; }
.submit-btn { background: #000; color: #fff; border: none; padding: 14px 24px; font-family: 'JetBrains Mono', monospace; font-weight: 700; cursor: pointer; transition: background 0.15s; width: 100%; }
.submit-btn:hover:not(:disabled) { background: #ff4500; }
.submit-btn:disabled { background: #e5e5e5; color: #999; cursor: not-allowed; }
.back-link { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: #666; text-decoration: none; text-align: center; }
.back-link:hover { color: #ff4500; }
</style>

View File

@ -0,0 +1,97 @@
<template>
<div class="auth-container">
<nav class="navbar"><div class="nav-brand">MIROFISH</div></nav>
<main class="auth-main">
<div class="auth-card">
<div class="card-header">
<span class="tag">AUTH</span>
<h1 class="title">{{ $t('resetPassword.title') }}</h1>
<p v-if="email" class="subtitle">{{ email }}</p>
</div>
<div v-if="error && !email" class="error-msg">{{ $t('resetPassword.invalidToken') }}</div>
<div v-else-if="done" class="success-msg">{{ $t('resetPassword.done') }}</div>
<form v-else-if="email" class="auth-form" @submit.prevent="handleSubmit">
<div class="field">
<label class="field-label">{{ $t('resetPassword.newPassword') }}</label>
<input v-model="password" type="password" class="field-input" :disabled="loading" minlength="8" />
</div>
<div class="field">
<label class="field-label">{{ $t('resetPassword.confirmPassword') }}</label>
<input v-model="confirm" type="password" class="field-input" :disabled="loading" />
</div>
<div v-if="formError" class="error-msg">{{ formError }}</div>
<button type="submit" class="submit-btn" :disabled="loading || !canSubmit">
<span v-if="loading">{{ $t('common.loading') }}</span>
<span v-else>{{ $t('resetPassword.submit') }} </span>
</button>
</form>
<router-link v-if="done" to="/login" class="back-link">{{ $t('resetPassword.goToLogin') }} </router-link>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import service from '../api/index'
const props = defineProps({ token: String })
const { t } = useI18n()
const email = ref('')
const password = ref('')
const confirm = ref('')
const loading = ref(false)
const error = ref('')
const formError = ref('')
const done = ref(false)
const canSubmit = computed(() => password.value.length >= 8 && password.value === confirm.value)
onMounted(async () => {
try {
const res = await service.get(`/api/auth/reset-password/${props.token}`)
email.value = res.data.email
} catch {
error.value = 'invalid'
}
})
async function handleSubmit() {
if (!canSubmit.value) { formError.value = t('resetPassword.passwordMismatch'); return }
loading.value = true; formError.value = ''
try {
await service.post('/api/auth/reset-password', { token: props.token, password: password.value })
done.value = true
} catch {
formError.value = t('common.unknownError')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.auth-container { min-height: 100vh; background: #fff; font-family: 'Space Grotesk', system-ui, sans-serif; color: #000; display: flex; flex-direction: column; }
.navbar { height: 60px; background: #000; color: #fff; display: flex; align-items: center; padding: 0 40px; }
.nav-brand { font-family: 'JetBrains Mono', monospace; font-weight: 800; letter-spacing: 1px; font-size: 1.2rem; }
.auth-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
.auth-card { width: 100%; max-width: 400px; border: 1px solid #e5e5e5; padding: 48px 40px; }
.card-header { margin-bottom: 32px; }
.tag { display: inline-block; background: #ff4500; color: #fff; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 700; padding: 2px 8px; letter-spacing: 1px; margin-bottom: 16px; }
.title { font-size: 1.8rem; font-weight: 500; margin-bottom: 8px; }
.subtitle { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #666; }
.auth-form { display: flex; flex-direction: column; gap: 20px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.field-label { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; }
.field-input { border: 1px solid #e5e5e5; background: #fafafa; padding: 12px 16px; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; outline: none; width: 100%; box-sizing: border-box; }
.field-input:focus { border-color: #000; background: #fff; }
.error-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: #ff4500; border-left: 3px solid #ff4500; padding-left: 12px; }
.success-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #22c55e; border-left: 3px solid #22c55e; padding-left: 12px; }
.submit-btn { background: #000; color: #fff; border: none; padding: 14px 24px; font-family: 'JetBrains Mono', monospace; font-weight: 700; cursor: pointer; transition: background 0.15s; width: 100%; }
.submit-btn:hover:not(:disabled) { background: #ff4500; }
.submit-btn:disabled { background: #e5e5e5; color: #999; cursor: not-allowed; }
.back-link { display: block; margin-top: 16px; font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #000; text-decoration: none; text-align: center; }
.back-link:hover { color: #ff4500; }
</style>

View File

@ -0,0 +1,100 @@
<template>
<div class="auth-container">
<nav class="navbar"><div class="nav-brand">MIROFISH</div></nav>
<main class="auth-main">
<div class="auth-card">
<div class="card-header">
<span class="tag">AUTH</span>
<h1 class="title">{{ $t('setPassword.title') }}</h1>
<p v-if="inviteData" class="subtitle">{{ inviteData.email }}</p>
</div>
<div v-if="tokenError" class="error-msg">{{ $t('setPassword.invalidToken') }}</div>
<div v-else-if="done" class="success-msg">{{ $t('setPassword.done') }}</div>
<form v-else-if="inviteData" class="auth-form" @submit.prevent="handleSubmit">
<div class="field">
<label class="field-label">{{ $t('setPassword.newPassword') }}</label>
<input v-model="password" type="password" class="field-input" :disabled="loading" minlength="8" />
</div>
<div class="field">
<label class="field-label">{{ $t('resetPassword.confirmPassword') }}</label>
<input v-model="confirm" type="password" class="field-input" :disabled="loading" />
</div>
<div v-if="formError" class="error-msg">{{ formError }}</div>
<button type="submit" class="submit-btn" :disabled="loading || !canSubmit">
<span v-if="loading">{{ $t('common.loading') }}</span>
<span v-else>{{ $t('setPassword.submit') }} </span>
</button>
</form>
<router-link v-if="done" to="/login?activated=1" class="back-link">{{ $t('resetPassword.goToLogin') }} </router-link>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import service from '../api/index'
const props = defineProps({ token: String })
const { t } = useI18n()
const router = useRouter()
const inviteData = ref(null)
const password = ref('')
const confirm = ref('')
const loading = ref(false)
const tokenError = ref(false)
const formError = ref('')
const done = ref(false)
const canSubmit = computed(() => password.value.length >= 8 && password.value === confirm.value)
onMounted(async () => {
try {
const res = await service.get(`/api/auth/invitation/${props.token}`)
inviteData.value = res.data
} catch {
tokenError.value = true
}
})
async function handleSubmit() {
if (!canSubmit.value) { formError.value = t('resetPassword.passwordMismatch'); return }
loading.value = true; formError.value = ''
try {
await service.post('/api/auth/set-password', { token: props.token, password: password.value })
done.value = true
setTimeout(() => router.push('/login?activated=1'), 2000)
} catch {
formError.value = t('common.unknownError')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.auth-container { min-height: 100vh; background: #fff; font-family: 'Space Grotesk', system-ui, sans-serif; color: #000; display: flex; flex-direction: column; }
.navbar { height: 60px; background: #000; color: #fff; display: flex; align-items: center; padding: 0 40px; }
.nav-brand { font-family: 'JetBrains Mono', monospace; font-weight: 800; letter-spacing: 1px; font-size: 1.2rem; }
.auth-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
.auth-card { width: 100%; max-width: 400px; border: 1px solid #e5e5e5; padding: 48px 40px; }
.card-header { margin-bottom: 32px; }
.tag { display: inline-block; background: #ff4500; color: #fff; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 700; padding: 2px 8px; letter-spacing: 1px; margin-bottom: 16px; }
.title { font-size: 1.8rem; font-weight: 500; margin-bottom: 8px; }
.subtitle { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #666; }
.auth-form { display: flex; flex-direction: column; gap: 20px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.field-label { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; }
.field-input { border: 1px solid #e5e5e5; background: #fafafa; padding: 12px 16px; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; outline: none; width: 100%; box-sizing: border-box; }
.field-input:focus { border-color: #000; background: #fff; }
.error-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: #ff4500; border-left: 3px solid #ff4500; padding-left: 12px; }
.success-msg { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #22c55e; border-left: 3px solid #22c55e; padding-left: 12px; }
.submit-btn { background: #000; color: #fff; border: none; padding: 14px 24px; font-family: 'JetBrains Mono', monospace; font-weight: 700; cursor: pointer; transition: background 0.15s; width: 100%; }
.submit-btn:hover:not(:disabled) { background: #ff4500; }
.submit-btn:disabled { background: #e5e5e5; color: #999; cursor: not-allowed; }
.back-link { display: block; margin-top: 16px; font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: #000; text-decoration: none; text-align: center; }
.back-link:hover { color: #ff4500; }
</style>