feat: Add JWT auth and nomenclature sync to data-entry-app
Integrate shared JWT authentication into data-entry-app: - Add Oracle pool initialization for auth service - Add AuthenticationMiddleware to protect API routes - Update all receipt endpoints to use CurrentUser from JWT - Add shared auth router (/api/auth/login, /api/auth/refresh) Add nomenclature synchronization feature: - Create SQLite models for synced suppliers, local suppliers, and cash registers - Add nomenclature router with sync triggers and CRUD endpoints - Add sync service for Oracle → SQLite nomenclature data - Update nomenclature_service to use synced SQLite data with fallbacks Create shared frontend components: - Add shared/frontend/ with LoginView.vue, auth store factory, login.css - Integrate shared login and auth into data-entry-app frontend - Add axios-based API service with token refresh interceptor 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,119 +1,11 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
/**
|
||||
* Auth Store for Reports App
|
||||
*
|
||||
* Uses the shared auth store factory from shared/frontend/stores/auth.js
|
||||
* Configured with the reports API service (port 8001)
|
||||
*/
|
||||
|
||||
import { createAuthStore } from "../../../../shared/frontend/stores/auth";
|
||||
import { apiService } from "../services/api";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
// State
|
||||
const accessToken = ref(localStorage.getItem("access_token"));
|
||||
const refreshToken = ref(localStorage.getItem("refresh_token"));
|
||||
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Getters
|
||||
const isAuthenticated = computed(() => !!accessToken.value);
|
||||
const currentUser = computed(() => user.value);
|
||||
|
||||
// Actions
|
||||
const login = async (credentials) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const loginData = {
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
};
|
||||
|
||||
const response = await apiService.post("/auth/login", loginData);
|
||||
const { access_token, refresh_token, user: userData } = response.data;
|
||||
|
||||
accessToken.value = access_token;
|
||||
refreshToken.value = refresh_token;
|
||||
user.value = userData;
|
||||
|
||||
localStorage.setItem("access_token", access_token);
|
||||
localStorage.setItem("refresh_token", refresh_token);
|
||||
localStorage.setItem("user", JSON.stringify(userData));
|
||||
|
||||
apiService.defaults.headers.common["Authorization"] =
|
||||
`Bearer ${access_token}`;
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.detail || "Login failed";
|
||||
return { success: false, error: error.value };
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
accessToken.value = null;
|
||||
refreshToken.value = null;
|
||||
user.value = null;
|
||||
error.value = null;
|
||||
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
// Note: selected_company is now per-user and persists across logout/login
|
||||
// It's stored as 'selected_company_${username}' in localStorage
|
||||
|
||||
delete apiService.defaults.headers.common["Authorization"];
|
||||
};
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
if (!refreshToken.value) {
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiService.post("/auth/refresh", {
|
||||
refresh_token: refreshToken.value,
|
||||
});
|
||||
|
||||
const { access_token } = response.data;
|
||||
|
||||
accessToken.value = access_token;
|
||||
localStorage.setItem("access_token", access_token);
|
||||
apiService.defaults.headers.common["Authorization"] =
|
||||
`Bearer ${access_token}`;
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Token refresh failed:", err);
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeAuth = () => {
|
||||
if (accessToken.value) {
|
||||
apiService.defaults.headers.common["Authorization"] =
|
||||
`Bearer ${accessToken.value}`;
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
currentUser,
|
||||
login,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
initializeAuth,
|
||||
clearError,
|
||||
};
|
||||
});
|
||||
export const useAuthStore = createAuthStore(apiService);
|
||||
|
||||
@@ -1,367 +1,16 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-wrapper">
|
||||
<Card class="login-card">
|
||||
<template #header>
|
||||
<div class="login-header">
|
||||
<i class="pi pi-chart-bar text-primary text-6xl"></i>
|
||||
<h1 class="login-title">ROA Reports</h1>
|
||||
<p class="login-subtitle">Rapoarte ERP - Facturi și Încasări</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label required"
|
||||
>Utilizator</label
|
||||
>
|
||||
<InputText
|
||||
id="username"
|
||||
v-model="credentials.username"
|
||||
placeholder="Introduceți numele de utilizator"
|
||||
:class="{ invalid: formErrors.username }"
|
||||
class="w-full"
|
||||
autocomplete="username"
|
||||
@blur="validateField('username')"
|
||||
/>
|
||||
<span v-if="formErrors.username" class="form-error">
|
||||
{{ formErrors.username }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label required">Parolă</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="credentials.password"
|
||||
placeholder="Introduceți parola"
|
||||
:class="{ invalid: formErrors.password }"
|
||||
class="w-full"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
autocomplete="current-password"
|
||||
@blur="validateField('password')"
|
||||
/>
|
||||
<span v-if="formErrors.password" class="form-error">
|
||||
{{ formErrors.password }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.error" class="error-message">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span>{{ authStore.error }}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="Conectare"
|
||||
class="w-full login-button"
|
||||
:loading="authStore.isLoading"
|
||||
:disabled="!isFormValid"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="login-footer">
|
||||
<small class="text-color-secondary">
|
||||
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<SharedLoginView
|
||||
app-title="ROA Reports"
|
||||
app-subtitle="Rapoarte ERP - Facturi și Încasări"
|
||||
app-icon="pi-chart-bar"
|
||||
redirect-path="/dashboard"
|
||||
:auth-store="authStore"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import SharedLoginView from "@shared/frontend/components/LoginView.vue";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Form data
|
||||
const credentials = ref({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const formErrors = ref({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
// Computed properties
|
||||
const currentYear = computed(() => new Date().getFullYear());
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
credentials.value.username.trim() !== "" &&
|
||||
credentials.value.password.trim() !== "" &&
|
||||
!formErrors.value.username &&
|
||||
!formErrors.value.password
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const validateField = (field) => {
|
||||
switch (field) {
|
||||
case "username":
|
||||
formErrors.value.username =
|
||||
credentials.value.username.trim() === ""
|
||||
? "Numele de utilizator este obligatoriu"
|
||||
: "";
|
||||
break;
|
||||
case "password":
|
||||
formErrors.value.password =
|
||||
credentials.value.password.trim() === ""
|
||||
? "Parola este obligatorie"
|
||||
: "";
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
validateField("username");
|
||||
validateField("password");
|
||||
return isFormValid.value;
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authStore.login(credentials.value);
|
||||
|
||||
if (result.success) {
|
||||
// Redirect to dashboard (removed welcome notification)
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "Eroare de conectare",
|
||||
detail: result.error || "Date de conectare incorecte",
|
||||
life: 5000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "Eroare",
|
||||
detail: "A apărut o eroare neașteptată",
|
||||
life: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Clear errors when user starts typing
|
||||
const clearErrors = () => {
|
||||
authStore.clearError();
|
||||
formErrors.value = {
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
};
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
// Clear any previous errors
|
||||
clearErrors();
|
||||
|
||||
// Focus on username field
|
||||
const usernameInput = document.getElementById("username");
|
||||
if (usernameInput) {
|
||||
usernameInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearErrors();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-light) 0%,
|
||||
var(--color-primary) 100%
|
||||
);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
padding: 2rem 2rem 1rem 2rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-primary-light) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: var(--color-primary) !important;
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
background-color: var(--red-50);
|
||||
color: var(--red-800);
|
||||
border: 1px solid var(--red-200);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--surface-50);
|
||||
border-top: 1px solid var(--surface-200);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
max-width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 0 1rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
/* Ensure inputs are touch-friendly */
|
||||
.p-inputtext,
|
||||
.p-password input {
|
||||
min-height: 44px;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 0 0.5rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for smooth transitions */
|
||||
.login-card {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user