From b5c4d4a336f7b51052d6763fdd9536127e83531b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 24 Apr 2026 18:46:39 +0000 Subject: [PATCH] feat(auth): add JWT login screen, production Dockerfile and Azure Container App Bicep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: POST /api/auth/login endpoint (PyJWT HS256, 24h expiry) require_auth before_request middleware protecting all /api/* routes except /login and /health; wsgi.py entry point for gunicorn; Flask serves compiled Vue SPA in production - Frontend: LoginView.vue (MiroFish dark aesthetic), auth.js reactive store, Axios Bearer token injection + 401 → /login redirect, Vue Router global guard protecting all routes - i18n: login keys added to en/zh/es/ca locale files - Dockerfile: multi-stage build (node:20-slim → python:3.11-slim + gunicorn), single port 5001 - Azure: container-app.bicep following CTTI guidelines — Log Analytics (NOR0016-C 90d retention), Container Apps Environment, all .env vars as env vars (secrets via secretRef, plain values inline) Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 11 +- Dockerfile | 66 +++++--- azure/container-app.bicep | 220 ++++++++++++++++++++++++++ backend/app/__init__.py | 68 +++++++-- backend/app/api/__init__.py | 2 + backend/app/api/auth.py | 28 ++++ backend/app/config.py | 1 + backend/pyproject.toml | 2 + backend/wsgi.py | 4 + frontend/src/api/index.js | 25 ++- frontend/src/router/index.js | 15 ++ frontend/src/store/auth.js | 26 ++++ frontend/src/views/LoginView.vue | 254 +++++++++++++++++++++++++++++++ locales/ca.json | 21 ++- locales/en.json | 21 ++- locales/es.json | 19 ++- locales/zh.json | 21 ++- 17 files changed, 756 insertions(+), 48 deletions(-) create mode 100644 azure/container-app.bicep create mode 100644 backend/app/api/auth.py create mode 100644 backend/wsgi.py create mode 100644 frontend/src/store/auth.js create mode 100644 frontend/src/views/LoginView.vue diff --git a/.env.example b/.env.example index 78a3b72c..be590005 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,13 @@ ZEP_API_KEY=your_zep_api_key_here # 注意如果不使用加速配置,env文件中就不要出现下面的配置项 LLM_BOOST_API_KEY=your_api_key_here LLM_BOOST_BASE_URL=your_base_url_here -LLM_BOOST_MODEL_NAME=your_model_name_here \ No newline at end of file +LLM_BOOST_MODEL_NAME=your_model_name_here + +# ===== Autenticació ===== +# Contrasenya de l'usuari "demo" — OBLIGATORI establir en producció +# Default buit = login deshabilitat fins que s'estableixi +DEMO_PASSWORD= + +# Flask secret key — per signar tokens JWT +# Generar amb: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=your-secret-key-here \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e6564686..2f4ec320 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,59 @@ -FROM python:3.11 +# ───────────────────────────────────────────── +# Stage 1: Build del frontend Vue +# ───────────────────────────────────────────── +FROM node:20-slim AS frontend-builder -# 安装 Node.js (满足 >=18)及必要工具 -RUN apt-get update \ - && apt-get install -y --no-install-recommends nodejs npm \ - && rm -rf /var/lib/apt/lists/* +WORKDIR /build -# 从 uv 官方镜像复制 uv +# Copiar manifests primer per aprofitar la caché de capes Docker +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +# Copiar el codi font i els fitxers de localització compartits +COPY frontend/ ./ +COPY locales/ ../locales/ + +# Build de producció (genera dist/) +RUN npm run build + +# ───────────────────────────────────────────── +# Stage 2: Imatge de producció Flask + gunicorn +# ───────────────────────────────────────────── +FROM python:3.11-slim AS production + +# Instal·lar uv per gestionar dependències Python COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/ WORKDIR /app -# 先复制依赖描述文件以利用缓存 -COPY package.json package-lock.json ./ -COPY frontend/package.json frontend/package-lock.json ./frontend/ +# Copiar i instal·lar dependències Python (aprofita caché si pyproject.toml no canvia) COPY backend/pyproject.toml backend/uv.lock ./backend/ +RUN cd backend && uv sync --frozen --no-dev -# 安装依赖(Node + Python) -RUN npm ci \ - && npm ci --prefix frontend \ - && cd backend && uv sync --frozen +# Copiar el codi font del backend i els fitxers compartits +COPY backend/ ./backend/ +COPY locales/ ./locales/ -# 复制项目源码 -COPY . . +# Copiar el frontend compilat al path que Flask utilitza per servir el SPA +COPY --from=frontend-builder /build/dist ./frontend/dist -EXPOSE 3000 5001 +# Variables d'entorn de producció +ENV FLASK_DEBUG=False \ + FLASK_HOST=0.0.0.0 \ + FLASK_PORT=5001 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 -# 同时启动前后端(开发模式) -CMD ["npm", "run", "dev"] \ No newline at end of file +EXPOSE 5001 + +# gunicorn: 1 worker (Container Apps escala horitzontalment via rèpliques) +# threads=4 per gestionar concurrència sense multiprocessing +# timeout=120s per a les operacions LLM de llarga durada +CMD ["backend/.venv/bin/gunicorn", \ + "--bind", "0.0.0.0:5001", \ + "--workers", "1", \ + "--threads", "4", \ + "--timeout", "120", \ + "--worker-class", "gthread", \ + "--chdir", "/app/backend", \ + "wsgi:application"] diff --git a/azure/container-app.bicep b/azure/container-app.bicep new file mode 100644 index 00000000..c4d2dda2 --- /dev/null +++ b/azure/container-app.bicep @@ -0,0 +1,220 @@ +// ───────────────────────────────────────────────────────────────────────────── +// MiroFish — Azure Container App +// Segueix les directrius CTTI per a desplegament d'aplicacions a Azure +// (DA/TM: Azure Container Apps com a plataforma de desplegament de contenidors) +// +// Paràmetres que l'equip d'ops ha de proporcionar en desplegar: +// Secrets (@secure): demoPassword, llmApiKey, llmBoostApiKey, zepApiKey, secretKey +// Valors: containerImage, llmBaseUrl, llmModelName, llmBoostBaseUrl, +// llmBoostModelName, oasisDefaultMaxRounds, +// reportAgentMaxToolCalls, reportAgentMaxReflectionRounds, +// reportAgentTemperature +// +// Extensions pendents per a l'equip d'operacions: +// - DNS: afegir CNAME a *.intranet.gencat.cat (PRE) / *.gencat.cat (PRO) +// - Xarxa: integrar en VNet Hub-Spoke + Private Link per a serveis interns +// (descomentar el bloc vnetConfiguration a l'entorn) +// - TLS: certificat gestionat via Container Apps o Azure Front Door +// - ingress.external: canviar a false per a accés exclusiu per intranet +// ───────────────────────────────────────────────────────────────────────────── + +@description('Nom base del projecte (es fa servir per als noms dels recursos)') +param projectName string = 'mirofish' + +@description('Localització Azure dels recursos') +param location string = resourceGroup().location + +@description('Imatge Docker completa (registry/imatge:tag)') +param containerImage string + +// ─── Paràmetres secrets (@secure — mai visibles als logs de desplegament) ──── + +@description('Contrasenya de l\'usuari demo') +@secure() +param demoPassword string + +@description('Clau de l\'API LLM principal (OpenAI-compatible)') +@secure() +param llmApiKey string + +@description('Clau de l\'API LLM acceleradora (opcional, deixar buit si no s\'usa)') +@secure() +param llmBoostApiKey string = '' + +@description('Clau de l\'API Zep Cloud') +@secure() +param zepApiKey string + +@description('SECRET_KEY de Flask per a JWT (generar amb: python -c "import secrets; print(secrets.token_hex(32))")') +@secure() +param secretKey string + +// ─── Paràmetres LLM principal ───────────────────────────────────────────────── + +@description('URL base de l\'API LLM principal') +param llmBaseUrl string = 'https://dashscope.aliyuncs.com/compatible-mode/v1' + +@description('Nom del model LLM principal') +param llmModelName string = 'qwen-plus' + +// ─── Paràmetres LLM accelerador (opcionals) ────────────────────────────────── + +@description('URL base de l\'API LLM acceleradora (opcional)') +param llmBoostBaseUrl string = '' + +@description('Nom del model LLM accelerador (opcional)') +param llmBoostModelName string = '' + +// ─── Paràmetres de simulació OASIS ─────────────────────────────────────────── + +@description('Nombre màxim de rondes per a la simulació OASIS') +param oasisDefaultMaxRounds string = '10' + +// ─── Paràmetres del Report Agent ───────────────────────────────────────────── + +@description('Nombre màxim de crides a eines del Report Agent') +param reportAgentMaxToolCalls string = '5' + +@description('Nombre màxim de rondes de reflexió del Report Agent') +param reportAgentMaxReflectionRounds string = '2' + +@description('Temperatura del model LLM per al Report Agent') +param reportAgentTemperature string = '0.5' + +// ─── Log Analytics Workspace ────────────────────────────────────────────────── +// NOR0016-C: retenció mínima de logs de seguretat = 90 dies +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { + name: '${projectName}-logs' + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 90 + } +} + +// ─── Container Apps Environment ─────────────────────────────────────────────── +resource containerAppsEnv 'Microsoft.App/managedEnvironments@2023-05-01' = { + name: '${projectName}-env' + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalytics.properties.customerId + sharedKey: logAnalytics.listKeys().primarySharedKey + } + } + // TODO (ops): descomentar per integrar en VNet Hub-Spoke CTTI + // vnetConfiguration: { + // infrastructureSubnetId: '/subscriptions/.../subnets/container-apps-subnet' + // internal: true // true = accés únicament per intranet + // } + } +} + +// ─── Container App ───────────────────────────────────────────────────────────── +resource containerApp 'Microsoft.App/containerApps@2023-05-01' = { + name: projectName + location: location + properties: { + managedEnvironmentId: containerAppsEnv.id + + configuration: { + // Secrets de Container Apps — mai en text pla a les variables d'entorn + secrets: [ + { name: 'demo-password', value: demoPassword } + { name: 'llm-api-key', value: llmApiKey } + { name: 'llm-boost-api-key', value: llmBoostApiKey } + { name: 'zep-api-key', value: zepApiKey } + { name: 'secret-key', value: secretKey } + ] + + // Ingrés: port únic 5001 (Flask serveix frontend + API) + ingress: { + external: true // TODO (ops): canviar a false en entorn intranet CTTI + targetPort: 5001 + transport: 'http' + // TODO (ops): afegir domini corporatiu quan estigui assignat + // customDomains: [ + // { + // name: 'mirofish.intranet.gencat.cat' // PRE + // certificateId: '' + // bindingType: 'SniEnabled' + // } + // ] + } + + // Revisió única activa (zero-downtime via revision-based deployments) + activeRevisionsMode: 'Single' + } + + template: { + containers: [ + { + name: projectName + image: containerImage + + env: [ + // ── Secrets referenciats per nom (mai valor en text pla) ── + { name: 'DEMO_PASSWORD', secretRef: 'demo-password' } + { name: 'LLM_API_KEY', secretRef: 'llm-api-key' } + { name: 'LLM_BOOST_API_KEY', secretRef: 'llm-boost-api-key' } + { name: 'ZEP_API_KEY', secretRef: 'zep-api-key' } + { name: 'SECRET_KEY', secretRef: 'secret-key' } + + // ── Variables no sensibles ── + { name: 'LLM_BASE_URL', value: llmBaseUrl } + { name: 'LLM_MODEL_NAME', value: llmModelName } + { name: 'LLM_BOOST_BASE_URL', value: llmBoostBaseUrl } + { name: 'LLM_BOOST_MODEL_NAME', value: llmBoostModelName } + + // ── Simulació OASIS ── + { name: 'OASIS_DEFAULT_MAX_ROUNDS', value: oasisDefaultMaxRounds } + + // ── Report Agent ── + { name: 'REPORT_AGENT_MAX_TOOL_CALLS', value: reportAgentMaxToolCalls } + { name: 'REPORT_AGENT_MAX_REFLECTION_ROUNDS', value: reportAgentMaxReflectionRounds } + { name: 'REPORT_AGENT_TEMPERATURE', value: reportAgentTemperature } + + // ── Flask ── + { name: 'FLASK_DEBUG', value: 'False' } + ] + + // Recursos mínim viable — escalar horitzontalment via rèpliques + resources: { + cpu: json('0.5') + memory: '1Gi' + } + } + ] + + // Escalat: mínim 1 rèplica (evita cold start en PRO), màxim 10 + scale: { + minReplicas: 1 + maxReplicas: 10 + rules: [ + { + name: 'http-scaling' + http: { + metadata: { + concurrentRequests: '20' + } + } + } + ] + } + } + } +} + +// ─── Outputs ────────────────────────────────────────────────────────────────── +@description('FQDN de l\'aplicació desplegada') +output containerAppFqdn string = containerApp.properties.configuration.ingress.fqdn + +@description('Nom del recurs Container App') +output containerAppName string = containerApp.name + +@description('ID del workspace de Log Analytics') +output logAnalyticsWorkspaceId string = logAnalytics.id diff --git a/backend/app/__init__.py b/backend/app/__init__.py index aba624bb..e5f3cf2d 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -9,45 +9,67 @@ import warnings # 需要在所有其他导入之前设置 warnings.filterwarnings("ignore", message=".*resource_tracker.*") -from flask import Flask, request +import jwt +from flask import Flask, request, jsonify from flask_cors import CORS from .config import Config from .utils.logger import setup_logger, get_logger +# Rutes públiques que no requereixen token JWT +_PUBLIC_PATHS = {'/health', '/api/auth/login'} + def create_app(config_class=Config): """Flask应用工厂函数""" app = Flask(__name__) app.config.from_object(config_class) - + # 设置JSON编码:确保中文直接显示(而不是 \uXXXX 格式) # Flask >= 2.3 使用 app.json.ensure_ascii,旧版本使用 JSON_AS_ASCII 配置 if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'): app.json.ensure_ascii = False - + # 设置日志 logger = setup_logger('mirofish') - + # 只在 reloader 子进程中打印启动信息(避免 debug 模式下打印两次) is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true' debug_mode = app.config.get('DEBUG', False) should_log_startup = not debug_mode or is_reloader_process - + if should_log_startup: logger.info("=" * 50) logger.info("MiroFish Backend 启动中...") logger.info("=" * 50) - + # 启用CORS CORS(app, resources={r"/api/*": {"origins": "*"}}) - + # 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程) from .services.simulation_runner import SimulationRunner SimulationRunner.register_cleanup() if should_log_startup: logger.info("已注册模拟进程清理函数") - + + # Middleware d'autenticació JWT — s'executa ABANS del log_request (ordre FIFO) + @app.before_request + def require_auth(): + if request.path in _PUBLIC_PATHS or request.method == 'OPTIONS': + return None + if not request.path.startswith('/api/'): + return None # rutes estàtiques del SPA no requereixen token + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return jsonify({'success': False, 'error': 'Missing token'}), 401 + token = auth_header[len('Bearer '):] + try: + jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256']) + except jwt.ExpiredSignatureError: + return jsonify({'success': False, 'error': 'Token expired'}), 401 + except jwt.InvalidTokenError: + return jsonify({'success': False, 'error': 'Invalid token'}), 401 + # 请求日志中间件 @app.before_request def log_request(): @@ -55,26 +77,40 @@ def create_app(config_class=Config): logger.debug(f"请求: {request.method} {request.path}") if request.content_type and 'json' in request.content_type: logger.debug(f"请求体: {request.get_json(silent=True)}") - + @app.after_request def log_response(response): logger = get_logger('mirofish.request') logger.debug(f"响应: {response.status_code}") return response - - # 注册蓝图 - from .api import graph_bp, simulation_bp, report_bp + + # 注册蓝图 (auth primer, luego els existents) + from .api import graph_bp, simulation_bp, report_bp, auth_bp + app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(graph_bp, url_prefix='/api/graph') app.register_blueprint(simulation_bp, url_prefix='/api/simulation') app.register_blueprint(report_bp, url_prefix='/api/report') - + # 健康检查 @app.route('/health') def health(): return {'status': 'ok', 'service': 'MiroFish Backend'} - + + # Servir el SPA de Vue compilat (producció: quan existeix frontend/dist/) + # La catch-all es registra al final perquè les rutes /api/* tinguin prioritat + import os as _os + from flask import send_from_directory, send_file as _send_file + _dist = _os.path.join(_os.path.dirname(__file__), '../../frontend/dist') + if _os.path.isdir(_dist): + @app.route('/', defaults={'path': ''}) + @app.route('/') + def serve_spa(path): + f = _os.path.join(_dist, path) + if path and _os.path.isfile(f): + return send_from_directory(_dist, path) + return _send_file(_os.path.join(_dist, 'index.html')) + if should_log_startup: logger.info("MiroFish Backend 启动完成") - - return app + return app diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index ffda743a..3155d510 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -7,8 +7,10 @@ from flask import Blueprint graph_bp = Blueprint('graph', __name__) simulation_bp = Blueprint('simulation', __name__) report_bp = Blueprint('report', __name__) +auth_bp = Blueprint('auth', __name__) from . import graph # noqa: E402, F401 from . import simulation # noqa: E402, F401 from . import report # noqa: E402, F401 +from . import auth # noqa: E402, F401 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 00000000..c080ae40 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,28 @@ +""" +Autenticació bàsica: POST /api/auth/login +Retorna JWT HS256 amb 24h d'expiració. +Si DEMO_PASSWORD és buida (no configurada), sempre retorna 401. +""" +import jwt +import datetime +from flask import request, jsonify, current_app +from . import auth_bp + + +@auth_bp.route('/login', methods=['POST']) +def login(): + data = request.get_json(silent=True) or {} + username = data.get('username', '') + password = data.get('password', '') + + expected = current_app.config.get('DEMO_PASSWORD', '') + if username != 'demo' or not expected or password != expected: + return jsonify({'success': False, 'error': 'Invalid credentials'}), 401 + + payload = { + 'sub': username, + 'iat': datetime.datetime.utcnow(), + 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24), + } + token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') + return jsonify({'success': True, 'token': token}) diff --git a/backend/app/config.py b/backend/app/config.py index 953dfa50..ebced558 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,6 +22,7 @@ class Config: # Flask配置 SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key') + DEMO_PASSWORD = os.environ.get('DEMO_PASSWORD', '') DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true' # JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4f5361d5..ccdd04f9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -32,6 +32,8 @@ dependencies = [ # 工具库 "python-dotenv>=1.0.0", "pydantic>=2.0.0", + "pyjwt>=2.8.0", + "gunicorn>=22.0.0", ] [project.optional-dependencies] diff --git a/backend/wsgi.py b/backend/wsgi.py new file mode 100644 index 00000000..4e6b478a --- /dev/null +++ b/backend/wsgi.py @@ -0,0 +1,4 @@ +"""Punt d'entrada WSGI per a gunicorn en producció.""" +from app import create_app + +application = create_app() diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e840e116..ec57106c 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,5 +1,6 @@ import axios from 'axios' import i18n from '../i18n' +import authState, { clearToken } from '../store/auth' // 创建axios实例 const service = axios.create({ @@ -14,6 +15,9 @@ const service = axios.create({ service.interceptors.request.use( config => { config.headers['Accept-Language'] = i18n.global.locale.value + if (authState.token) { + config.headers['Authorization'] = `Bearer ${authState.token}` + } return config }, error => { @@ -26,28 +30,37 @@ service.interceptors.request.use( service.interceptors.response.use( response => { const res = response.data - + // 如果返回的状态码不是success,则抛出错误 if (!res.success && res.success !== undefined) { console.error('API Error:', res.error || res.message || 'Unknown error') return Promise.reject(new Error(res.error || res.message || 'Error')) } - + return res }, error => { console.error('Response error:', error) - + + // Token invàlid o expirat: netejar sessió i redirigir al login + if (error.response?.status === 401) { + clearToken() + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } + return Promise.reject(error) + } + // 处理超时 if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) { console.error('Request timeout') } - + // 处理网络错误 if (error.message === 'Network Error') { console.error('Network error - please check your connection') } - + return Promise.reject(error) } ) @@ -59,7 +72,7 @@ export const requestWithRetry = async (requestFn, maxRetries = 3, delay = 1000) return await requestFn() } catch (error) { if (i === maxRetries - 1) throw error - + console.warn(`Request failed, retrying (${i + 1}/${maxRetries})...`) await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i))) } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 62d23201..e6a38a14 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -5,8 +5,16 @@ import SimulationView from '../views/SimulationView.vue' 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' const routes = [ + { + path: '/login', + name: 'Login', + component: LoginView, + meta: { public: true } + }, { path: '/', name: 'Home', @@ -49,4 +57,11 @@ const router = createRouter({ 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.name === 'Login') return next({ name: 'Home' }) + next() +}) + export default router diff --git a/frontend/src/store/auth.js b/frontend/src/store/auth.js new file mode 100644 index 00000000..dbb581dc --- /dev/null +++ b/frontend/src/store/auth.js @@ -0,0 +1,26 @@ +import { reactive } from 'vue' + +const AUTH_KEY = 'mirofish_token' + +const state = reactive({ + token: localStorage.getItem(AUTH_KEY) || null, + isAuthenticated: !!localStorage.getItem(AUTH_KEY) +}) + +export function setToken(token) { + state.token = token + state.isAuthenticated = true + localStorage.setItem(AUTH_KEY, token) +} + +export function clearToken() { + state.token = null + state.isAuthenticated = false + localStorage.removeItem(AUTH_KEY) +} + +export function getToken() { + return state.token +} + +export default state diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 00000000..7716a4ae --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/locales/ca.json b/locales/ca.json index 090cc82b..e445b8cb 100644 --- a/locales/ca.json +++ b/locales/ca.json @@ -75,7 +75,13 @@ "layoutGraph": "Graf", "layoutSplit": "Dividit", "layoutWorkbench": "Taulell", - "stepNames": ["Construcció del graf", "Configuració de l'entorn", "Execució de la simulació", "Generació de l'informe", "Interacció profunda"] + "stepNames": [ + "Construcció del graf", + "Configuració de l'entorn", + "Execució de la simulació", + "Generació de l'informe", + "Interacció profunda" + ] }, "step1": { "ontologyGeneration": "Generació d'ontologia", @@ -661,5 +667,16 @@ "llmSelectAgentFailed": "La selecció d'agents per LLM ha fallat, usant la selecció per defecte: {error}", "generateInterviewQuestionsFailed": "Error en generar les preguntes d'entrevista: {error}", "generateInterviewSummaryFailed": "Error en generar el resum de l'entrevista: {error}" + }, + "login": { + "title": "Accés", + "subtitle": "// Accés autenticat requerit", + "username": "Usuari", + "usernamePlaceholder": "demo", + "password": "Contrasenya", + "passwordPlaceholder": "••••••••", + "submit": "Entrar", + "loading": "Autenticant...", + "invalidCredentials": "Usuari o contrasenya incorrectes" } -} +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 544c68b1..f0f32ddc 100644 --- a/locales/en.json +++ b/locales/en.json @@ -75,7 +75,13 @@ "layoutGraph": "Graph", "layoutSplit": "Split", "layoutWorkbench": "Workbench", - "stepNames": ["Graph Build", "Env Setup", "Run Simulation", "Report Generation", "Deep Interaction"] + "stepNames": [ + "Graph Build", + "Env Setup", + "Run Simulation", + "Report Generation", + "Deep Interaction" + ] }, "step1": { "ontologyGeneration": "Ontology Generation", @@ -661,5 +667,16 @@ "llmSelectAgentFailed": "LLM agent selection failed, using default selection: {error}", "generateInterviewQuestionsFailed": "Failed to generate interview questions: {error}", "generateInterviewSummaryFailed": "Failed to generate interview summary: {error}" + }, + "login": { + "title": "Access", + "subtitle": "// Authenticated access required", + "username": "Username", + "usernamePlaceholder": "demo", + "password": "Password", + "passwordPlaceholder": "••••••••", + "submit": "Enter", + "loading": "Authenticating...", + "invalidCredentials": "Invalid username or password" } -} +} \ No newline at end of file diff --git a/locales/es.json b/locales/es.json index 5420f671..f2d4d915 100644 --- a/locales/es.json +++ b/locales/es.json @@ -75,7 +75,13 @@ "layoutGraph": "Grafo", "layoutSplit": "Doble columna", "layoutWorkbench": "Área de trabajo", - "stepNames": ["Construcción del grafo", "Configuración del entorno", "Ejecutar simulación", "Generación de informe", "Interacción profunda"] + "stepNames": [ + "Construcción del grafo", + "Configuración del entorno", + "Ejecutar simulación", + "Generación de informe", + "Interacción profunda" + ] }, "step1": { "ontologyGeneration": "Generación de ontología", @@ -661,5 +667,16 @@ "llmSelectAgentFailed": "Selección de agente por LLM falló, usando selección por defecto: {error}", "generateInterviewQuestionsFailed": "Error al generar preguntas de entrevista: {error}", "generateInterviewSummaryFailed": "Error al generar resumen de entrevista: {error}" + }, + "login": { + "title": "Acceso", + "subtitle": "// Acceso autenticado requerido", + "username": "Usuario", + "usernamePlaceholder": "demo", + "password": "Contraseña", + "passwordPlaceholder": "••••••••", + "submit": "Entrar", + "loading": "Autenticando...", + "invalidCredentials": "Usuario o contraseña incorrectos" } } \ No newline at end of file diff --git a/locales/zh.json b/locales/zh.json index cd747e2f..21791b42 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -75,7 +75,13 @@ "layoutGraph": "图谱", "layoutSplit": "双栏", "layoutWorkbench": "工作台", - "stepNames": ["图谱构建", "环境搭建", "开始模拟", "报告生成", "深度互动"] + "stepNames": [ + "图谱构建", + "环境搭建", + "开始模拟", + "报告生成", + "深度互动" + ] }, "step1": { "ontologyGeneration": "本体生成", @@ -661,5 +667,16 @@ "llmSelectAgentFailed": "LLM选择Agent失败,使用默认选择: {error}", "generateInterviewQuestionsFailed": "生成采访问题失败: {error}", "generateInterviewSummaryFailed": "生成采访摘要失败: {error}" + }, + "login": { + "title": "访问", + "subtitle": "// 需要身份验证", + "username": "用户名", + "usernamePlaceholder": "demo", + "password": "密码", + "passwordPlaceholder": "••••••••", + "submit": "登录", + "loading": "验证中...", + "invalidCredentials": "用户名或密码错误" } -} +} \ No newline at end of file