diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e87337c..840aea5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,6 +29,9 @@ jobs: run: | docker buildx build --platform linux/amd64 \ -t ${{ secrets.DOCKER_USERNAME }}/hscodes:${{ github.run_number }} \ + --build-arg VITE_AUTH0_DOMAIN=${{ secrets.AUTH0_DOMAIN }} \ + --build-arg VITE_AUTH0_CLIENT_ID=${{ secrets.AUTH0_CLIENTID }} \ + --build-arg VITE_AUTH0_AUDIENCE=${{ secrets.AUTH0_AUDIENCE }} \ --file Dockerfile \ --push . diff --git a/Autumn.SPA/.env.example b/Autumn.SPA/.env.example new file mode 100644 index 0000000..f6834f4 --- /dev/null +++ b/Autumn.SPA/.env.example @@ -0,0 +1,3 @@ +VITE_AUTH0_DOMAIN=your-tenant.auth0.com +VITE_AUTH0_CLIENT_ID=your-client-id +VITE_AUTH0_AUDIENCE=autumnapi diff --git a/Autumn.SPA/.gitignore b/Autumn.SPA/.gitignore index a547bf3..61cb0c2 100644 --- a/Autumn.SPA/.gitignore +++ b/Autumn.SPA/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.vite # Editor directories and files .vscode/* diff --git a/Autumn.SPA/package-lock.json b/Autumn.SPA/package-lock.json index 6b8a00c..dc2d5d0 100644 --- a/Autumn.SPA/package-lock.json +++ b/Autumn.SPA/package-lock.json @@ -8,6 +8,7 @@ "name": "autumn-spa", "version": "0.0.0", "dependencies": { + "@auth0/auth0-react": "^2.14.0", "@tailwindcss/vite": "^4.1.18", "lucide-react": "^0.564.0", "react": "^19.2.0", @@ -26,6 +27,41 @@ "vite": "^7.3.1" } }, + "node_modules/@auth0/auth0-auth-js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-auth-js/-/auth0-auth-js-1.4.0.tgz", + "integrity": "sha512-ShA7KT4KvcBEtxsXZTcrmoNxai5q1JXhB2aEBFnZD1L6LNLzzmiUWiFTtGMsaaITCylr8TJ/onEQk6XZmUHXbg==", + "license": "MIT", + "dependencies": { + "jose": "^6.0.8", + "openid-client": "^6.8.0" + } + }, + "node_modules/@auth0/auth0-react": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.14.0.tgz", + "integrity": "sha512-Uyn9mB+pOJ0ko81aJoTbOJok11E4rFeNXhzNFLsvWaUD7dp0Qe3B9zwsbQhaCxrsJOX1UXg0y4ck/RjwNnsPoA==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.15.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", + "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.15.0.tgz", + "integrity": "sha512-cXv1Isyy4JEc+GxesQPFj3SEbDSnCVQTGiardY9WLSoYTsMMU585Kgm9TJFPJO4dDq3wi+DSJoy3IUcB3rr9nA==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-auth-js": "^1.4.0", + "browser-tabs-lock": "^1.2.15", + "dpop": "^2.1.1", + "es-cookie": "~1.3.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1760,6 +1796,16 @@ "concat-map": "0.0.1" } }, + "node_modules/browser-tabs-lock": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", + "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1933,6 +1979,15 @@ "node": ">=8" } }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -1953,6 +2008,12 @@ "node": ">=10.13.0" } }, + "node_modules/es-cookie": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", + "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2450,6 +2511,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2806,6 +2876,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2893,6 +2969,28 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz", + "integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3058,6 +3156,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, diff --git a/Autumn.SPA/package.json b/Autumn.SPA/package.json index 1787163..9828fc8 100644 --- a/Autumn.SPA/package.json +++ b/Autumn.SPA/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@auth0/auth0-react": "^2.14.0", "@tailwindcss/vite": "^4.1.18", "lucide-react": "^0.564.0", "react": "^19.2.0", diff --git a/Autumn.SPA/src/App.jsx b/Autumn.SPA/src/App.jsx index 8be8a45..240afe3 100644 --- a/Autumn.SPA/src/App.jsx +++ b/Autumn.SPA/src/App.jsx @@ -1,11 +1,13 @@ -import { useState, useEffect } from "react"; -import { api } from "./api"; +import { useState, useEffect, useContext } from "react"; +import { Auth0Context } from "@auth0/auth0-react"; +import { api, setAuthToken } from "./api"; import Header from "./components/Header"; import Home from "./components/Home"; import SearchView from "./components/SearchView"; import CalcView from "./components/CalcView"; import BrowseView from "./components/BrowseView"; import Toast from "./components/Toast"; +import CookieConsent from "./components/CookieConsent"; export default function App() { const [mode, setMode] = useState("light"); @@ -13,9 +15,18 @@ export default function App() { const [query, setQuery] = useState(""); const [country, setCountry] = useState("NG"); const [countries, setCountries] = useState([]); - // Cross-view state for calculator prefill const [calcInit, setCalcInit] = useState({ hscode: "", product: "" }); + // Only use Auth0 context when env vars are configured + const authEnabled = !!(import.meta.env.VITE_AUTH0_DOMAIN && import.meta.env.VITE_AUTH0_CLIENT_ID); + const rawAuth = useContext(Auth0Context); + const auth = authEnabled ? rawAuth : null; + + useEffect(() => { + if (!auth?.isAuthenticated || !auth.getAccessTokenSilently) return; + auth.getAccessTokenSilently().then(setAuthToken).catch(() => {}); + }, [auth?.isAuthenticated]); + useEffect(() => { document.documentElement.classList.toggle("dark", mode === "dark"); }, [mode]); @@ -33,13 +44,11 @@ export default function App() { setQuery(""); }; - // Navigate from SearchView → CalcView with HS code prefilled const goToCalc = (hscode, product) => { setCalcInit({ hscode: hscode || "", product: product || "" }); setView("calculator"); }; - // Navigate from CalcView → SearchView to find an HS code const goToSearch = (product) => { setQuery(product || ""); setView("results"); @@ -56,6 +65,7 @@ export default function App() { mode={mode} setMode={setMode} onReset={onReset} + auth={auth} /> {view === "home" && ( @@ -100,10 +110,10 @@ export default function App() { )} +
© 2025 HS.Codes — Open source commodity classification. - GitHub →
); diff --git a/Autumn.SPA/src/api.js b/Autumn.SPA/src/api.js index e1d27dc..6d777a2 100644 --- a/Autumn.SPA/src/api.js +++ b/Autumn.SPA/src/api.js @@ -1,5 +1,10 @@ const BASE = '/api'; +let _token = null; +export const setAuthToken = (token) => { _token = token; }; + +const headers = () => _token ? { Authorization: `Bearer ${_token}` } : {}; + const json = async (r) => { if (r.status === 429) { window.dispatchEvent(new CustomEvent('api:ratelimit')); @@ -10,7 +15,7 @@ const json = async (r) => { export const api = { search: (keyword) => - fetch(`${BASE}/search?keyword=${encodeURIComponent(keyword)}`).then(json), + fetch(`${BASE}/search?keyword=${encodeURIComponent(keyword)}`, { headers: headers() }).then(json), browse: ({ code, parentCode, parentId, level } = {}) => { const params = new URLSearchParams(); @@ -18,7 +23,7 @@ export const api = { if (parentCode) params.set('parentCode', parentCode); if (parentId) params.set('parentId', parentId); if (level != null) params.set('level', String(level)); - return fetch(`${BASE}/browse?${params}`).then(json); + return fetch(`${BASE}/browse?${params}`, { headers: headers() }).then(json); }, duty: ({ HSCode, Country, ProductDesc, Cost, Freight, Insurance, Currency }) => @@ -26,17 +31,17 @@ export const api = { HSCode, Country, ProductDesc: ProductDesc || '', Cost: String(Cost), Freight: String(Freight), Insurance: String(Insurance), Currency: Currency || 'USD' - })}`).then(json), + })}`, { headers: headers() }).then(json), note: (hscode, country) => - fetch(`${BASE}/note/${encodeURIComponent(hscode)}?country=${encodeURIComponent(country)}`).then(json), + fetch(`${BASE}/note/${encodeURIComponent(hscode)}?country=${encodeURIComponent(country)}`, { headers: headers() }).then(json), countries: () => - fetch(`${BASE}/codelist/countries`).then(json), + fetch(`${BASE}/codelist/countries`, { headers: headers() }).then(json), currencies: () => - fetch(`${BASE}/codelist/currency`).then(json), + fetch(`${BASE}/codelist/currency`, { headers: headers() }).then(json), products: (query) => - fetch(`${BASE}/codelist/products/${encodeURIComponent(query || '')}`).then(json), + fetch(`${BASE}/codelist/products/${encodeURIComponent(query || '')}`, { headers: headers() }).then(json), }; diff --git a/Autumn.SPA/src/components/CookieConsent.jsx b/Autumn.SPA/src/components/CookieConsent.jsx new file mode 100644 index 0000000..3db72d1 --- /dev/null +++ b/Autumn.SPA/src/components/CookieConsent.jsx @@ -0,0 +1,67 @@ +import { useState, useEffect } from "react"; + +const GA_ID = "UA-159243847-1"; +const CONSENT_KEY = "hs_cookie_consent"; + +function loadGA() { + if (document.getElementById("ga-script")) return; + const s = document.createElement("script"); + s.id = "ga-script"; + s.async = true; + s.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`; + document.head.appendChild(s); + window.dataLayer = window.dataLayer || []; + function gtag() { window.dataLayer.push(arguments); } + gtag("js", new Date()); + gtag("config", GA_ID); +} + +export default function CookieConsent() { + const [show, setShow] = useState(false); + + useEffect(() => { + const consent = localStorage.getItem(CONSENT_KEY); + if (consent === "accepted") { + loadGA(); + } else if (!consent) { + setShow(true); + } + }, []); + + const accept = () => { + localStorage.setItem(CONSENT_KEY, "accepted"); + loadGA(); + setShow(false); + }; + + const decline = () => { + localStorage.setItem(CONSENT_KEY, "declined"); + setShow(false); + }; + + if (!show) return null; + + return ( +
+
+

+ We use cookies to analyse site usage and improve our service. +

+
+ + +
+
+
+ ); +} diff --git a/Autumn.SPA/src/components/Header.jsx b/Autumn.SPA/src/components/Header.jsx index 2a8adae..3d63698 100644 --- a/Autumn.SPA/src/components/Header.jsx +++ b/Autumn.SPA/src/components/Header.jsx @@ -1,8 +1,15 @@ -import { Sun, Moon } from "lucide-react"; +import { Sun, Moon, LogIn, LogOut } from "lucide-react"; import OwlLogo from "./OwlLogo"; import CountrySelector from "./CountrySelector"; -export default function Header({ country, setCountry, countries, view, setView, mode, setMode, onReset }) { +export default function Header({ country, setCountry, countries, view, setView, mode, setMode, onReset, auth }) { + const isAuthenticated = auth?.isAuthenticated ?? false; + const isLoading = auth?.isLoading ?? false; + const user = auth?.user ?? null; + const error = auth?.error ?? null; + + if (error) console.error("Auth0 error:", error); + return (
@@ -25,6 +32,32 @@ export default function Header({ country, setCountry, countries, view, setView, className="w-[30px] h-[30px] rounded-[7px] border border-border bg-surface text-fg-sec flex items-center justify-center cursor-pointer transition-all duration-200"> {mode === "dark" ? : } + + {auth && !isLoading && ( + <> +
+ {isAuthenticated ? ( + + ) : ( + + )} + + )}
diff --git a/Autumn.SPA/src/main.jsx b/Autumn.SPA/src/main.jsx index 0c51ee7..285ee9c 100644 --- a/Autumn.SPA/src/main.jsx +++ b/Autumn.SPA/src/main.jsx @@ -1,10 +1,25 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { Auth0Provider } from '@auth0/auth0-react' import './app.css' import App from './App.jsx' +const domain = import.meta.env.VITE_AUTH0_DOMAIN || ''; +const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID || ''; +const audience = import.meta.env.VITE_AUTH0_AUDIENCE || ''; + createRoot(document.getElementById('root')).render( - + {domain && clientId ? ( + + + + ) : ( + + )} , ) diff --git a/Dockerfile b/Dockerfile index 6b71271..11d009c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ # Stage 1: Build React SPA FROM node:20-alpine AS spa-build +ARG VITE_AUTH0_DOMAIN="" +ARG VITE_AUTH0_CLIENT_ID="" +ARG VITE_AUTH0_AUDIENCE="" WORKDIR /app COPY Autumn.SPA/package*.json ./ RUN npm ci