diff --git a/.dockerignore b/.dockerignore index eda2084..f51fc37 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,57 @@ +# Dependencies node_modules + +# Build output dist + +# Git .git .gitignore + +# Docker files (não precisam estar dentro da imagem) Dockerfile docker-compose.yml -pnpm-lock.yaml +.dockerignore + +# Arquivos de lock (npm/yarn/pnpm) +package-lock.json +yarn.lock +# Mantém pnpm-lock.yaml pois é necessário para build reproduzível + +# Documentação +*.md +README.md + +# Environment files (usar apenas .env.example como referência) +.env +.env.local +.env.development +.env.production +.env.*.local + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage +.nyc_output + +# Cache +.cache +.eslintcache +.stylelintcache diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca88e85 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Ambiente (development ou production) +VITE_ENV=production + +# URL da API do back-end +# No Dokploy com Traefik, usar o nome do serviço interno +VITE_API_URL=http://clipperia-api:3000 diff --git a/Dockerfile b/Dockerfile index f4df3b7..bb652cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,11 @@ COPY . . RUN pnpm install - ARG BASE=/ +ARG VITE_API_URL=https://api.clipperia.com.br ENV VITE_BASE_PATH=$BASE -RUN pnpm run build +ENV VITE_API_URL=$VITE_API_URL +RUN pnpm build RUN rm -rf node_modules FROM nginx:1.27-alpine diff --git a/docker-compose.yml b/docker-compose.yml index 85aec5c..c09052d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,18 +5,21 @@ services: dockerfile: Dockerfile args: BASE: ${BASE:-/} + VITE_API_URL: ${VITE_API_URL:-https://api.clipperia.com.br} no_cache: true - image: ${IMAGE_NAME:-vite-nginx:latest} + image: ${IMAGE_NAME:-clipperia-web:latest} container_name: clipperia restart: unless-stopped networks: - dokploy-network + # Labels do Traefik gerenciados pelo Dokploy + # Se necessário configurar manualmente: # labels: # - traefik.enable=true - # - traefik.http.routers.vite.rule=Host("clipperia.com.br") - # - traefik.http.routers.vite.entrypoints=websecure - # - traefik.http.routers.vite.tls.certresolver=letsencrypt - # - traefik.http.services.vite.loadbalancer.server.port=80 + # - traefik.http.routers.clipperia.rule=Host(`clipperia.com.br`) + # - traefik.http.routers.clipperia.entrypoints=websecure + # - traefik.http.routers.clipperia.tls.certresolver=letsencrypt + # - traefik.http.services.clipperia.loadbalancer.server.port=80 networks: dokploy-network: external: true diff --git a/nginx.conf b/nginx.conf index 7ec555e..35b7822 100644 --- a/nginx.conf +++ b/nginx.conf @@ -8,13 +8,35 @@ server { root /usr/share/nginx/html; index index.html; + # Configurações para proxy reverso (Traefik) + real_ip_header X-Forwarded-For; + set_real_ip_from 0.0.0.0/0; + + # Cabeçalhos de segurança + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Logs + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # Cache de assets estáticos location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { expires 7d; add_header Cache-Control "public, max-age=604800, immutable"; try_files $uri =404; } + # SPA routing - todas as rotas vão para index.html location / { try_files $uri $uri/ /index.html; } + + # Health check endpoint (opcional) + location /health { + access_log off; + return 200 "OK"; + add_header Content-Type text/plain; + } } diff --git a/src/auth/router.js b/src/auth/router.js index cd561f4..b09e964 100644 --- a/src/auth/router.js +++ b/src/auth/router.js @@ -4,6 +4,7 @@ import { createWebHistory, createRouter } from "vue-router"; import roles from "@/auth/roles"; import { extractRolesFromToken } from "@/utils/keycloak"; +import { hasToken, validateToken, redirectToLogin } from "@/utils/auth"; import Videos from "@/routes/videos"; import NewVideo from "@/routes/videos/new"; @@ -139,37 +140,49 @@ const hasPermission = (requiredPermissions = []) => { ); }; -// router.beforeEach((to, from, next) => { -// if (to.meta.title) { -// document.title = to.meta.title; -// } +router.beforeEach(async (to, from, next) => { + if (to.meta.title) { + document.title = to.meta.title; + } -// console.log(to, from); + const requiresAuth = to.matched.some((record) => record.meta.requiresAuth); + const isGuestRoute = to.matched.some((record) => record.meta.guest); -// // if (to.matched.some((record) => record.meta.requiresAuth)) { -// if (!isAuthenticated()) { -// next({ -// path: "/login", -// }); + if (requiresAuth) { + if (!hasToken()) { + next({ path: "/login" }); + return; + } -// return; -// } + const isValid = await validateToken(); -// // const requiredPermissions = to.meta.permissions || []; + if (!isValid) { + redirectToLogin(); + return; + } -// // if (requiredPermissions.length > 0 && !hasPermission(requiredPermissions)) { -// // next({ path: "/unauthorized" }); + const requiredPermissions = to.meta.permissions || []; -// // return; -// // } -// // } + if (requiredPermissions.length > 0 && !hasPermission(requiredPermissions)) { + next({ path: "/videos" }); + return; + } -// // if (to.matched.some((record) => record.meta.guest) && isAuthenticated()) { -// // next({ path: "/" }); + next(); + return; + } -// // return; -// // } + if (isGuestRoute && hasToken()) { + const isValid = await validateToken(); -// next(); -// return; -// }); + if (isValid) { + next({ path: "/videos" }); + return; + } + + redirectToLogin(); + return; + } + + next(); +}); diff --git a/src/config/axios.js b/src/config/axios.js index 65ccd34..c3a660d 100644 --- a/src/config/axios.js +++ b/src/config/axios.js @@ -2,10 +2,7 @@ import axios from "axios"; import Cookies from "js-cookie"; export const API = axios.create({ - baseURL: "http://localhost:3000", - // process.env.NODE_ENV === "development" - // ? "https://api.clipperia.com.br" - // : "http://nestjs:3000", + baseURL: import.meta.env.VITE_API_URL || "http://localhost:3000", }); API.interceptors.request.use((config) => { @@ -21,7 +18,7 @@ API.interceptors.request.use((config) => { API.interceptors.response.use( (response) => response, (error) => { - if (error.response.status === 401) { + if (error.response?.status === 401) { Cookies.remove("token"); window.location.href = "/login"; } diff --git a/src/routes/videos/index.vue b/src/routes/videos/index.vue index 95e0222..c003a16 100644 --- a/src/routes/videos/index.vue +++ b/src/routes/videos/index.vue @@ -68,6 +68,8 @@ :options="situations" :loading="situationsLoading" multiple + emit-value + map-options /> @@ -232,6 +234,9 @@ export default { page: pagination.page, ...baseParams, }, + paramsSerializer: { + indexes: null, + }, }); this.rows = data.content; diff --git a/src/utils/auth.js b/src/utils/auth.js new file mode 100644 index 0000000..562efcb --- /dev/null +++ b/src/utils/auth.js @@ -0,0 +1,47 @@ +import Cookies from "js-cookie"; +import { API } from "../config/axios"; + +/** + * Checks if the user has a valid session token + * @returns {boolean} True if token cookie exists + */ +export function hasToken() { + const token = Cookies.get("token"); + return !!token; +} + +/** + * Validates the current token by calling the backend /check-token endpoint + * @returns {Promise} True if token is valid, false otherwise + */ +export async function validateToken() { + if (!hasToken()) { + return false; + } + + try { + const response = await API.get("/auth/check-token"); + return response.data?.valid === true; + } catch (error) { + // If the request fails (401, 403, network error, etc.), token is invalid + return false; + } +} + +/** + * Clears all authentication data from cookies + */ +export function clearAuthData() { + Cookies.remove("token"); + Cookies.remove("refresh_token"); + Cookies.remove("user_roles"); + Cookies.remove("user_profile"); +} + +/** + * Redirects to login page and clears auth data + */ +export function redirectToLogin() { + clearAuthData(); + window.location.href = "/login"; +}