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:
parent
fa39d6b55e
commit
746c3bd286
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<div class="admin-placeholder">
|
||||
<!-- AdminView — pending T15 implementation -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ tab: String })
|
||||
</script>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue