From 22b3899aca24852654a52da938cf78273983a1e5 Mon Sep 17 00:00:00 2001 From: taskifbin Date: Sun, 11 Oct 2026 10:10:03 +0600 Subject: [PATCH 01/10] ci: update deploy workflow paths from web to client --- .github/workflows/deploy.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5c3ce37..795374c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,9 +21,9 @@ jobs: - name: Check required files run: | test -f docker-compose.yml - test -f web/Dockerfile - test -f web/nginx.conf - test -f web/public/index.html + test -f client/Dockerfile + test -f client/nginx.conf + test -f client/public/index.html test -f db/init.sql - name: Create dummy .env file @@ -76,7 +76,7 @@ jobs: - name: Copy app and .env to server run: | - tar --exclude='./.git' --exclude='./.github' -czf paynext.tgz docker-compose.yml web db + tar --exclude='./.git' --exclude='./.github' -czf paynext.tgz docker-compose.yml client db scp paynext.tgz "$SSH_USER@$SERVER_IP":/tmp/paynext.tgz scp server.env "$SSH_USER@$SERVER_IP":/opt/paynext/.env rm -f server.env paynext.tgz @@ -87,7 +87,7 @@ jobs: set -e cd /opt/paynext docker compose down --remove-orphans || true - rm -rf web db docker-compose.yml + rm -rf client db docker-compose.yml tar -xzf /tmp/paynext.tgz rm -f /tmp/paynext.tgz docker compose up -d --build From 0951d4cce0696e4fa79db0b5ffd9c3b644139778 Mon Sep 17 00:00:00 2001 From: NaosinLineya Date: Sun, 11 Oct 2026 11:16:09 +0600 Subject: [PATCH 02/10] created Create style.css --- client/public/css/style.css | 170 ++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 client/public/css/style.css diff --git a/client/public/css/style.css b/client/public/css/style.css new file mode 100644 index 0000000..ee6f905 --- /dev/null +++ b/client/public/css/style.css @@ -0,0 +1,170 @@ + +:root { + font-family: system-ui, sans-serif; + color: #202536; + background: #f4f6fb; + font-synthesis: none; + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 16px 6%; + background: #172554; + color: white; +} + +.brand { + color: white; + font-size: 1.15rem; + font-weight: 700; + text-decoration: none; +} + +.nav-links { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.nav-links a { + color: white; + text-decoration: none; +} + +.nav-links a:hover { + text-decoration: underline; +} + +.container { + width: min(100% - 32px, 680px); + margin: 48px auto; + flex: 1; +} + +.card { + padding: 28px; + background: white; + border: 1px solid #e0e5ef; + border-radius: 14px; + box-shadow: 0 8px 24px rgb(20 30 60 / 6%); +} + +h1 { + margin-top: 0; + font-size: 1.8rem; +} + +.muted { + color: #687187; +} + +.form-group { + margin: 18px 0; +} + +label { + display: block; + margin-bottom: 6px; + font-weight: 600; +} + +input { + width: 100%; + padding: 12px; + border: 1px solid #cbd2df; + border-radius: 8px; + font: inherit; +} + +input:focus { + outline: 2px solid #93c5fd; + border-color: #2563eb; +} + +.button { + display: inline-block; + padding: 11px 18px; + border: 0; + border-radius: 8px; + background: #2563eb; + color: white; + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} + +.button:hover { + background: #1d4ed8; +} + +.button:disabled { + opacity: 0.65; + cursor: wait; +} + +.button-small { + padding: 7px 12px; +} + +.form-button { + width: 100%; +} + +.message { + margin: 14px 0; + padding: 10px 12px; + border-radius: 8px; + overflow-wrap: anywhere; +} + +.message-error { + color: #991b1b; + background: #fee2e2; +} + +.message-success { + color: #166534; + background: #dcfce7; +} + +.hidden { + display: none !important; +} + +.footer { + padding: 18px; + text-align: center; + color: #687187; +} + +@media (max-width: 520px) { + .navbar { + align-items: flex-start; + flex-direction: column; + } + + .nav-links { + gap: 12px; + } + + .card { + padding: 20px; + } +} From 60037895baaa321e614efc7fe72c02ec11799418 Mon Sep 17 00:00:00 2001 From: NaosinLineya Date: Sun, 11 Oct 2026 11:17:12 +0600 Subject: [PATCH 03/10] Create api.js --- client/js/api.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 client/js/api.js diff --git a/client/js/api.js b/client/js/api.js new file mode 100644 index 0000000..5f04119 --- /dev/null +++ b/client/js/api.js @@ -0,0 +1,37 @@ + +const API_BASE_URL = "http://localhost:5000/api"; + +async function apiRequest(endpoint, options = {}) { + const token = Auth.getToken(); + + const headers = { + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...options.headers, + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + }); + + const contentType = response.headers.get("content-type") || ""; + const data = contentType.includes("application/json") + ? await response.json() + : null; + + if (!response.ok) { + if (response.status === 401) { + Auth.clearToken(); + } + + throw new Error( + data?.error || data?.message || `Request failed (${response.status})` + ); + } + + return data; +} From bdde634c7a16e1bdb2d650da58865ae6e232d20c Mon Sep 17 00:00:00 2001 From: NaosinLineya Date: Sun, 11 Oct 2026 11:17:43 +0600 Subject: [PATCH 04/10] Create auth.js --- client/js/auth.js | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 client/js/auth.js diff --git a/client/js/auth.js b/client/js/auth.js new file mode 100644 index 0000000..2f1fd59 --- /dev/null +++ b/client/js/auth.js @@ -0,0 +1,56 @@ + +const Auth = { + getToken() { + return localStorage.getItem("token"); + }, + + saveToken(token) { + localStorage.setItem("token", token); + }, + + clearToken() { + localStorage.removeItem("token"); + }, + + isLoggedIn() { + return Boolean(this.getToken()); + }, + + async login(email, password) { + const data = await apiRequest("/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); + + if (!data?.token) { + throw new Error("The server did not return a login token."); + } + + this.saveToken(data.token); + return data; + }, + + async register(name, email, password) { + const data = await apiRequest("/auth/register", { + method: "POST", + body: JSON.stringify({ name, email, password }), + }); + + // Some APIs automatically log in new users. + // Others require the user to log in separately. + if (data?.token) { + this.saveToken(data.token); + } + + return data; + }, + + logout() { + this.clearToken(); + window.location.hash = "#/login"; + }, + + async getCurrentUser() { + return apiRequest("/auth/me"); + }, +}; From 064e1c4c7f7d3e970405d084550838acc6c6edac Mon Sep 17 00:00:00 2001 From: NaosinLineya Date: Sun, 11 Oct 2026 11:18:35 +0600 Subject: [PATCH 05/10] Create login.js --- client/js/views/login.js | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 client/js/views/login.js diff --git a/client/js/views/login.js b/client/js/views/login.js new file mode 100644 index 0000000..effdf50 --- /dev/null +++ b/client/js/views/login.js @@ -0,0 +1,60 @@ + +function renderLogin() { + return ` +
+

Welcome back

+

Log in to access your account.

+ +
+ +
+
+ + +
+ +
+ + +
+ + +
+ +

+ New here? Create an account. +

+
+ `; +} + +function initLogin() { + const form = document.getElementById("login-form"); + if (!form) return; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + + const button = form.querySelector('button[type="submit"]'); + const message = document.getElementById("login-message"); + const email = form.elements.email.value.trim(); + const password = form.elements.password.value; + + button.disabled = true; + message.textContent = ""; + + try { + await Auth.login(email, password); + window.location.hash = "#/dashboard"; + } catch (error) { + message.className = "message message-error"; + message.textContent = error.message || "Login failed."; + } finally { + button.disabled = false; + } + }); +} From fe5993cca0bf51faeff12ce9ce3f6c20864b241d Mon Sep 17 00:00:00 2001 From: NaosinLineya Date: Sun, 11 Oct 2026 11:19:38 +0600 Subject: [PATCH 06/10] Create register.js --- client/js/views/register.js | 82 +++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 client/js/views/register.js diff --git a/client/js/views/register.js b/client/js/views/register.js new file mode 100644 index 0000000..a6b4e00 --- /dev/null +++ b/client/js/views/register.js @@ -0,0 +1,82 @@ + +function renderRegister() { + return ` +
+

Create your account

+

Register to get started.

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already registered? Log in. +

+
+ `; +} + +function initRegister() { + const form = document.getElementById("register-form"); + if (!form) return; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + + const button = form.querySelector('button[type="submit"]'); + const message = document.getElementById("register-message"); + + const name = form.elements.name.value.trim(); + const email = form.elements.email.value.trim(); + const password = form.elements.password.value; + + if (password.length < 8) { + message.className = "message message-error"; + message.textContent = "Password must be at least 8 characters."; + return; + } + + button.disabled = true; + message.textContent = ""; + + try { + const data = await Auth.register(name, email, password); + + if (Auth.isLoggedIn()) { + window.location.hash = "#/dashboard"; + } else { + message.className = "message message-success"; + message.textContent = + data?.message || "Registration successful. Please log in."; + form.reset(); + } + } catch (error) { + message.className = "message message-error"; + message.textContent = error.message || "Registration failed."; + } finally { + button.disabled = false; + } + }); +} From 31358015ecf202c43eb0efaa6d02c61ef355d718 Mon Sep 17 00:00:00 2001 From: NaosinLineya Date: Sun, 11 Oct 2026 11:20:26 +0600 Subject: [PATCH 07/10] Create dashboard.js --- client/js/views/dashboard.js | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 client/js/views/dashboard.js diff --git a/client/js/views/dashboard.js b/client/js/views/dashboard.js new file mode 100644 index 0000000..4901faf --- /dev/null +++ b/client/js/views/dashboard.js @@ -0,0 +1,43 @@ + +function renderDashboard() { + return ` +
+

Your Dashboard

+

You are viewing a protected page.

+ +
+ Loading your account... +
+ +
+
+ `; +} + +async function initDashboard() { + const message = document.getElementById("dashboard-message"); + const profile = document.getElementById("dashboard-profile"); + + try { + const data = await Auth.getCurrentUser(); + + // Support either { user: {...} } or a direct user object. + const user = data?.user || data; + + const name = user?.name || "User"; + const email = user?.email || "No email available"; + + message.textContent = "You are successfully authenticated."; + + const heading = document.createElement("h2"); + heading.textContent = `Welcome, ${name}`; + + const emailParagraph = document.createElement("p"); + emailParagraph.textContent = `Email: ${email}`; + + profile.replaceChildren(heading, emailParagraph); + } catch (error) { + Auth.clearToken(); + window.location.hash = "#/login"; + } +} From cb2c421a0dd57d5650efcffb97629a4b9512483b Mon Sep 17 00:00:00 2001 From: NaosinLineya Date: Sun, 11 Oct 2026 11:20:53 +0600 Subject: [PATCH 08/10] Create app.js --- client/js/app.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 client/js/app.js diff --git a/client/js/app.js b/client/js/app.js new file mode 100644 index 0000000..58594e3 --- /dev/null +++ b/client/js/app.js @@ -0,0 +1,69 @@ + +const appView = document.getElementById("app-view"); +const logoutButton = document.getElementById("logout-button"); + +const routes = { + "/login": { + render: renderLogin, + init: initLogin, + }, + "/register": { + render: renderRegister, + init: initRegister, + }, + "/dashboard": { + render: renderDashboard, + init: initDashboard, + protected: true, + }, +}; + +function updateNavigation() { + const loggedIn = Auth.isLoggedIn(); + + logoutButton.classList.toggle("hidden", !loggedIn); + + document.querySelectorAll("[data-nav]").forEach((link) => { + const isCurrent = link.dataset.nav === + window.location.hash.replace(/^#\//, ""); + + link.setAttribute("aria-current", isCurrent ? "page" : "false"); + }); +} + +async function navigate() { + let path = window.location.hash.slice(1) || "/login"; + + if (!path.startsWith("/")) { + path = `/${path}`; + } + + const route = routes[path]; + + if (!route) { + window.location.hash = "#/login"; + return; + } + + if (route.protected && !Auth.isLoggedIn()) { + window.location.hash = "#/login"; + return; + } + + appView.innerHTML = route.render(); + updateNavigation(); + + if (route.init) { + await route.init(); + } + + updateNavigation(); +} + +logoutButton.addEventListener("click", () => { + Auth.logout(); +}); + +window.addEventListener("hashchange", navigate); + +navigate(); From dae7c2b7b498cee2193582bb3b2608a950cdeebd Mon Sep 17 00:00:00 2001 From: Tanvir Alam Shihab Date: Sat, 5 Sep 2026 13:31:14 +0600 Subject: [PATCH 09/10] feat: complete Lab 05 Authentication and RBAC Authorization --- README.md | 36 ++++++++++++++++++++++++ server/src/controllers/authController.js | 10 ++++++- server/src/middleware/auth.js | 13 +++++++-- server/src/models/userModel.js | 13 +++++++-- server/src/routes/authRoutes.js | 7 +++-- 5 files changed, 71 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fccc98b..8e40a5f 100644 --- a/README.md +++ b/README.md @@ -111,4 +111,40 @@ docker compose down -v Use the `-v` option only when you want to remove the stored local database data. +## 🔐 Authentication & Authorization (Lab 05) + +### Registration (`POST /api/v1/auth/register`) +Request: +```json +{ "email": "user@test.com", "password": "password123", "fullName": "Test User" } +``` +Response (`201 Created`): +```json +{ "success": true, "message": "Registered successfully", "data": { "token": "jwt_string..." } } +``` + +### Login (`POST /api/v1/auth/login`) +Request: +```json +{ "email": "user@test.com", "password": "password123" } +``` +Response (`200 OK`): +```json +{ "success": true, "message": "Login successful", "data": { "token": "jwt_string..." } } +``` + +### Admin Restricted Endpoint (`GET /api/v1/auth/users`) +Requires: `Authorization: Bearer ` +Response (`200 OK`): +```json +{ + "success": true, + "data": [ + { "id": 1, "email": "admin@test.com", "role": "admin" }, + { "id": 2, "email": "user@test.com", "role": "user" } + ] +} +``` +If accessed with a standard user token, returns `403 Forbidden`. + payNEXT - Powering Your Next Move. Next Generation Digital Wallet. diff --git a/server/src/controllers/authController.js b/server/src/controllers/authController.js index ebeef33..0142b16 100644 --- a/server/src/controllers/authController.js +++ b/server/src/controllers/authController.js @@ -90,5 +90,13 @@ const me = async (req, res) => { res.status(500).json({ success: false, error: "Server error" }); } }; +const getAllUsers = async (req, res, next) => { + try { + const users = await userModel.findAll(); + res.json({ success: true, data: users }); + } catch (e) { + next(e); + } +}; -module.exports = { register, login, me }; +module.exports = { register, login, me, getAllUsers }; diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js index 080e1a9..ad61235 100644 --- a/server/src/middleware/auth.js +++ b/server/src/middleware/auth.js @@ -1,7 +1,7 @@ const jwt = require("jsonwebtoken"); function signToken(user) { - return jwt.sign({ sub: user.id, email: user.email }, process.env.JWT_SECRET, { + return jwt.sign({ sub: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: "7d", }); } @@ -21,4 +21,13 @@ function requireAuth(req, res, next) { } } -module.exports = { signToken, requireAuth }; +function requireRole(role) { + return (req, res, next) => { + if (!req.user || req.user.role !== role) { + return res.status(403).json({ success: false, error: "Forbidden: insufficient permissions" }); + } + next(); + }; +} + +module.exports = { signToken, requireAuth, requireRole }; diff --git a/server/src/models/userModel.js b/server/src/models/userModel.js index f02b456..b425866 100644 --- a/server/src/models/userModel.js +++ b/server/src/models/userModel.js @@ -7,7 +7,7 @@ async function findByEmail(email) { async function findById(id) { const r = await pool.query( - "SELECT id, email, full_name, phone, status, created_at FROM users WHERE id = $1", + "SELECT id, email, full_name, phone, role, status, created_at FROM users WHERE id = $1", [id], ); return r.rows[0]; @@ -17,10 +17,17 @@ async function create({ email, passwordHash, fullName, phone }) { const r = await pool.query( `INSERT INTO users (email, password_hash, full_name, phone) VALUES ($1, $2, $3, $4) - RETURNING id, email, full_name, phone, status, created_at`, + RETURNING id, email, full_name, phone, role, status, created_at`, [email, passwordHash, fullName, phone], ); return r.rows[0]; } -module.exports = { findByEmail, findById, create }; +async function findAll() { + const r = await pool.query( + "SELECT id, email, full_name, phone, role, status, created_at FROM users ORDER BY created_at DESC" + ); + return r.rows; +} + +module.exports = { findByEmail, findById, create, findAll }; diff --git a/server/src/routes/authRoutes.js b/server/src/routes/authRoutes.js index 465e4fe..85c5c91 100644 --- a/server/src/routes/authRoutes.js +++ b/server/src/routes/authRoutes.js @@ -1,9 +1,9 @@ const express = require("express"); const router = express.Router(); const { body, validationResult } = require("express-validator"); - const authLimiter = require("../middleware/rateLimiter"); -const { login, register, me } = require("../controllers/authController"); +const { register, login, me, getAllUsers } = require("../controllers/authController"); +const { requireAuth, requireRole } = require("../middleware/auth"); // Helper middleware to check for validation errors const validate = (req, res, next) => { @@ -54,4 +54,7 @@ router.post( register, ); +router.get("/me", requireAuth, me); +router.get("/users", requireAuth, requireRole("admin"), getAllUsers); + module.exports = router; From bbacf4ce9ac0665500e4359c84a0c5d063f5020b Mon Sep 17 00:00:00 2001 From: Tanvir Alam Shihab Date: Sun, 11 Oct 2026 11:34:01 +0600 Subject: [PATCH 10/10] feat(auth): connect registration and login UI with centralized token storage --- Caddyfile | 2 +- client/public/css/style.css | 679 +++++++++++++++++++++++++++++++----- client/public/index.html | 566 ++++++++++++++++-------------- client/public/js/api.js | 82 +++++ client/public/js/app.js | 365 +++++++++++++++++++ client/public/js/auth.js | 88 +++++ 6 files changed, 1431 insertions(+), 351 deletions(-) create mode 100644 client/public/js/api.js create mode 100644 client/public/js/app.js create mode 100644 client/public/js/auth.js diff --git a/Caddyfile b/Caddyfile index abb95c7..7983335 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,4 +1,4 @@ -paynextt.me, www.paynextt.me { +paynextt.me, www.paynextt.me, localhost { encode zstd gzip diff --git a/client/public/css/style.css b/client/public/css/style.css index ee6f905..458bf13 100644 --- a/client/public/css/style.css +++ b/client/public/css/style.css @@ -1,170 +1,671 @@ +/* payNEXT Design System & Stylesheet */ :root { - font-family: system-ui, sans-serif; - color: #202536; - background: #f4f6fb; - font-synthesis: none; - line-height: 1.5; + --bg-primary: #090d16; + --bg-secondary: #0f172a; + --bg-card: rgba(30, 41, 59, 0.7); + --bg-card-hover: rgba(30, 41, 59, 0.9); + --border-color: rgba(255, 255, 255, 0.08); + --border-focus: #38bdf8; + + --text-main: #f8fafc; + --text-muted: #94a3b8; + --text-dim: #64748b; + + --accent-cyan: #38bdf8; + --accent-cyan-dark: #0284c7; + --accent-indigo: #818cf8; + --accent-emerald: #10b981; + --accent-rose: #f43f5e; + --accent-amber: #f59e0b; + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-full: 9999px; + + --shadow-glow: 0 0 25px rgba(56, 189, 248, 0.15); + --shadow-card: 0 10px 30px -10px rgba(0, 0, 0, 0.5); + --transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); } * { + margin: 0; + padding: 0; box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } body { - margin: 0; + background: radial-gradient(circle at 50% 0%, #1e1b4b 0%, var(--bg-primary) 70%); + color: var(--text-main); min-height: 100vh; display: flex; flex-direction: column; + overflow-x: hidden; } -.navbar { +/* Header & Navigation */ +header { + position: sticky; + top: 0; + z-index: 100; display: flex; justify-content: space-between; align-items: center; - gap: 16px; - padding: 16px 6%; - background: #172554; - color: white; + padding: 18px 40px; + background: rgba(9, 13, 22, 0.85); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border-color); } -.brand { - color: white; - font-size: 1.15rem; - font-weight: 700; +.logo-container { + display: flex; + align-items: center; + gap: 10px; text-decoration: none; } -.nav-links { +.logo-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-indigo)); + display: flex; + align-items: center; + justify-content: center; + font-weight: 900; + font-size: 18px; + color: #090d16; +} + +.logo-text { + font-size: 24px; + font-weight: 800; + letter-spacing: -0.5px; + color: #ffffff; +} + +.logo-text span { + color: var(--accent-cyan); +} + +nav { display: flex; align-items: center; gap: 16px; - flex-wrap: wrap; } -.nav-links a { - color: white; +nav a { + color: var(--text-muted); text-decoration: none; + font-size: 14px; + font-weight: 500; + padding: 8px 14px; + border-radius: var(--radius-sm); + transition: var(--transition); } -.nav-links a:hover { - text-decoration: underline; +nav a:hover, nav a.active { + color: #ffffff; + background: rgba(255, 255, 255, 0.05); +} + +.user-badge { + display: flex; + align-items: center; + gap: 8px; + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.25); + padding: 6px 14px; + border-radius: var(--radius-full); + font-size: 13px; + font-weight: 600; + color: var(--accent-cyan); +} + +/* Views Management */ +.view-section { + display: none; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.view-section.active { + display: block; + opacity: 1; + transform: translateY(0); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 11px 22px; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + text-decoration: none; + transition: var(--transition); +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent-cyan), #0284c7); + color: #090d16; + font-weight: 700; +} + +.btn-primary:hover { + filter: brightness(1.1); + box-shadow: var(--shadow-glow); + transform: translateY(-1px); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.05); + border-color: var(--border-color); + color: var(--text-main); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); +} + +.btn-danger { + background: rgba(244, 63, 94, 0.15); + border-color: rgba(244, 63, 94, 0.3); + color: var(--accent-rose); +} + +.btn-danger:hover { + background: rgba(244, 63, 94, 0.25); } -.container { - width: min(100% - 32px, 680px); - margin: 48px auto; - flex: 1; +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* Auth Cards & Layout */ +.auth-container { + max-width: 440px; + margin: 60px auto 80px; + padding: 0 20px; } .card { - padding: 28px; - background: white; - border: 1px solid #e0e5ef; - border-radius: 14px; - box-shadow: 0 8px 24px rgb(20 30 60 / 6%); + background: var(--bg-card); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 36px 32px; + box-shadow: var(--shadow-card); +} + +.auth-header { + text-align: center; + margin-bottom: 28px; } -h1 { - margin-top: 0; - font-size: 1.8rem; +.auth-header h2 { + font-size: 26px; + font-weight: 700; + margin-bottom: 8px; + color: #ffffff; } -.muted { - color: #687187; +.auth-header p { + color: var(--text-muted); + font-size: 14px; } +/* Forms */ .form-group { - margin: 18px 0; + margin-bottom: 20px; } -label { +.form-group label { display: block; - margin-bottom: 6px; + font-size: 13px; font-weight: 600; + color: #cbd5e1; + margin-bottom: 8px; } -input { +.form-control { width: 100%; - padding: 12px; - border: 1px solid #cbd2df; - border-radius: 8px; - font: inherit; + padding: 12px 16px; + background: rgba(15, 23, 42, 0.8); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: #ffffff; + font-size: 14px; + transition: var(--transition); + outline: none; } -input:focus { - outline: 2px solid #93c5fd; - border-color: #2563eb; +.form-control:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2); } -.button { - display: inline-block; - padding: 11px 18px; - border: 0; - border-radius: 8px; - background: #2563eb; - color: white; - font: inherit; - font-weight: 600; - cursor: pointer; +.form-control::placeholder { + color: var(--text-dim); +} + +.form-hint { + font-size: 12px; + color: var(--text-dim); + margin-top: 5px; +} + +.auth-footer-text { + text-align: center; + margin-top: 24px; + font-size: 13px; + color: var(--text-muted); +} + +.auth-footer-text a { + color: var(--accent-cyan); text-decoration: none; + font-weight: 600; } -.button:hover { - background: #1d4ed8; +.auth-footer-text a:hover { + text-decoration: underline; } -.button:disabled { - opacity: 0.65; - cursor: wait; +/* Error/Alert Banners */ +.form-error-banner { + display: none; + background: rgba(244, 63, 94, 0.12); + border: 1px solid rgba(244, 63, 94, 0.3); + color: #fca5a5; + padding: 12px 16px; + border-radius: var(--radius-sm); + font-size: 13px; + line-height: 1.5; + margin-bottom: 20px; } -.button-small { - padding: 7px 12px; +/* Dashboard Layout */ +.dashboard-container { + max-width: 1100px; + margin: 40px auto 80px; + padding: 0 24px; } -.form-button { - width: 100%; +.dash-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 32px; +} + +.dash-title h1 { + font-size: 32px; + font-weight: 800; + margin-bottom: 6px; +} + +.dash-title p { + color: var(--text-muted); + font-size: 15px; +} + +.grid-dashboard { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 24px; + margin-bottom: 32px; +} + +.dash-card { + background: var(--bg-card); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 28px; + box-shadow: var(--shadow-card); + position: relative; + overflow: hidden; +} + +.dash-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-indigo)); +} + +.dash-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.dash-card-header h3 { + font-size: 18px; + font-weight: 700; + color: #ffffff; +} + +.badge { + display: inline-block; + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-success { + background: rgba(16, 185, 129, 0.15); + border: 1px solid rgba(16, 185, 129, 0.3); + color: var(--accent-emerald); +} + +.badge-warning { + background: rgba(245, 158, 11, 0.15); + border: 1px solid rgba(245, 158, 11, 0.3); + color: var(--accent-amber); } -.message { +.badge-cyan { + background: rgba(56, 189, 248, 0.15); + border: 1px solid rgba(56, 189, 248, 0.3); + color: var(--accent-cyan); +} + +.detail-row { + display: flex; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + font-size: 14px; +} + +.detail-row:last-child { + border-bottom: none; +} + +.detail-label { + color: var(--text-muted); +} + +.detail-value { + font-weight: 600; + color: #ffffff; +} + +.token-preview-box { + background: rgba(15, 23, 42, 0.9); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 12px 14px; + font-family: monospace; + font-size: 12px; + color: #86efac; + word-break: break-all; margin: 14px 0; - padding: 10px 12px; - border-radius: 8px; - overflow-wrap: anywhere; } -.message-error { - color: #991b1b; - background: #fee2e2; +/* Wallet Card Preview (Week 2 ready) */ +.wallet-card-preview { + background: linear-gradient(135deg, #1e1b4b, #0f172a); + border: 1px solid rgba(129, 140, 248, 0.3); + border-radius: var(--radius-md); + padding: 24px; + margin-top: 10px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); +} + +.wallet-num-tag { + font-size: 12px; + color: var(--accent-indigo); + letter-spacing: 1px; + font-weight: 700; + margin-bottom: 8px; +} + +.wallet-balance-amount { + font-size: 34px; + font-weight: 800; + color: #ffffff; + margin-bottom: 16px; +} + +.wallet-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* Hero Section (Home) */ +.hero { + max-width: 1000px; + margin: 0 auto; + padding: 90px 24px 50px; + text-align: center; +} + +.hero-pill { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.2); + color: var(--accent-cyan); + padding: 6px 16px; + border-radius: var(--radius-full); + font-size: 13px; + font-weight: 600; + margin-bottom: 24px; +} + +.hero h1 { + font-size: 56px; + line-height: 1.1; + font-weight: 900; + margin-bottom: 20px; + letter-spacing: -1px; +} + +.hero h1 span { + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-indigo)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.hero p { + max-width: 680px; + margin: 0 auto 36px; + color: var(--text-muted); + font-size: 18px; + line-height: 1.6; +} + +.features-grid { + max-width: 1100px; + margin: 20px auto 80px; + padding: 0 24px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; } -.message-success { - color: #166534; - background: #dcfce7; +.feature-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 28px; + transition: var(--transition); +} + +.feature-card:hover { + transform: translateY(-4px); + border-color: rgba(56, 189, 248, 0.3); + box-shadow: var(--shadow-card); +} + +.feature-icon { + font-size: 28px; + margin-bottom: 16px; +} + +.feature-card h3 { + font-size: 18px; + font-weight: 700; + margin-bottom: 10px; + color: #ffffff; +} + +.feature-card p { + color: var(--text-muted); + font-size: 14px; + line-height: 1.6; +} + +/* API Status Section */ +.status-section { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px 80px; + text-align: center; +} + +.status-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 32px; + max-width: 600px; + margin: 0 auto; +} + +/* Toasts */ +#global-toast-container { + position: fixed; + top: 85px; + right: 24px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 10px; +} + +.toast { + min-width: 280px; + max-width: 420px; + padding: 14px 18px; + border-radius: var(--radius-sm); + background: #1e293b; + border: 1px solid var(--border-color); + color: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4); + font-size: 14px; + animation: slideIn 0.3s ease-out; +} + +.toast-success { + border-color: rgba(16, 185, 129, 0.5); + background: #064e3b; +} + +.toast-info { + border-color: rgba(56, 189, 248, 0.5); + background: #0c4a6e; +} + +.toast-warning { + border-color: rgba(245, 158, 11, 0.5); + background: #78350f; +} + +.toast-close { + background: transparent; + border: none; + color: #ffffff; + font-size: 18px; + cursor: pointer; + margin-left: 12px; + opacity: 0.7; +} + +.toast-close:hover { + opacity: 1; } -.hidden { - display: none !important; +.toast.fade-out { + opacity: 0; + transform: translateX(20px); + transition: all 0.4s ease; } -.footer { - padding: 18px; +/* Spinner */ +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #ffffff; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes slideIn { + from { opacity: 0; transform: translateX(30px); } + to { opacity: 1; transform: translateX(0); } +} + +footer { + margin-top: auto; text-align: center; - color: #687187; + padding: 28px; + border-top: 1px solid var(--border-color); + color: var(--text-dim); + font-size: 13px; + background: rgba(9, 13, 22, 0.5); } -@media (max-width: 520px) { - .navbar { - align-items: flex-start; +@media (max-width: 768px) { + header { + padding: 16px 20px; flex-direction: column; + gap: 16px; } - - .nav-links { - gap: 12px; + .hero h1 { + font-size: 38px; } - - .card { - padding: 20px; + .grid-dashboard { + grid-template-columns: 1fr; + } + #global-toast-container { + left: 20px; + right: 20px; } } diff --git a/client/public/index.html b/client/public/index.html index 0775b87..2029d15 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -4,274 +4,318 @@ payNEXT - Digital Wallet - - + + +
+ +
- + +
P
+
payNEXT
+
+ + + -
-
-

Simple Digital Wallet for Fast Payments

- -

- payNEXT allows users to store money digitally, send money to other - users, top up wallets, and track transactions securely. -

- - -
- -
-
-

Send Money

-

- Transfer money instantly from one payNEXT wallet to another wallet. -

-
- -
-

Top Up Wallet

-

- Add funds to your wallet using bank transfer, card payment, or agent - top-up. -

-
- -
-

Transaction History

-

- View all credits, debits, transfers, and wallet activity in one place. -

-
- -
-

Secure by Design

-

- Wallet operations are protected using authentication, validation, and - database transactions. -

-
-
- -
-

API Health Check

-
- - - -
- -
Click the button to check server health
-
- -
© 2026 payNEXT. All rights reserved.
- - + +
+ +
+
+
⚡ payNEXT Digital Financial Core
+

Simple Digital Wallet for Fast Payments

+

+ payNEXT allows users to store money digitally, send money to other + users, top up wallets, and track transactions securely. +

+ +
+ +
+
+
💸
+

Send Money

+

+ Transfer funds instantly from one payNEXT wallet to another with + zero latency and ACID transactional security. +

+
+ +
+
💳
+

Top Up Wallet

+

+ Add funds seamlessly to your digital balance using bank transfer, + card payments, or merchant agents. +

+
+ +
+
📊
+

Transaction History

+

+ Audit all credits, debits, transfers, and wallet activity in one + centralized, immutable transaction ledger. +

+
+ +
+
🛡️
+

JWT & Role Security

+

+ Protected by Bearer token authorization, bcrypt hashing, rate + limiting, and strict input sanitization. +

+
+
+ + +
+
+

System Health Verification

+

+ Verify connectivity to both the Nginx Web reverse proxy and the Node.js Express REST API. +

+ +
+ Click button above to ping /healthz and /api/v1/health +
+
+
+
+ + +
+
+
+
+

Welcome Back

+

Sign in to your payNEXT digital wallet

+
+ +
+ +
+
+ + +
+ +
+ + +
+ + +
+ + +
+
+
+ + +
+
+
+
+

Create Account

+

Join payNEXT and activate your digital wallet

+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
Must contain at least 6 characters
+
+ +
+ + +
+ + +
+ + +
+
+
+ + +
+
+
+
+

Wallet Dashboard

+

Manage your account credentials, token security, and wallet balance

+
+
+ +
+
+ + +
+ +
+
+

User Profile

+ USER +
+
+ Full Name + Loading... +
+
+ Email + Loading... +
+
+ Phone + Loading... +
+
+ Account ID + Loading... +
+
+ + +
+
+

Token Security & Storage

+ JWT ACTIVE +
+

+ JWT Bearer token is saved in localStorage.getItem('paynext_token') and automatically injected into the HTTP Authorization header by js/api.js. +

+
+ Retrieving active Bearer token... +
+
+ +
+
+ + +
+
+

Digital Wallet Balance

+ Week 2 Target +
+
+
PAYNX-000001
+
$0.00 USD
+
+ + + + +
+
+
+
+
+
+
+ + +
+

© 2026 payNEXT Digital Wallet. Built for CSE 362 - Web Design and Programming Laboratory-II.

+
+ + + + + diff --git a/client/public/js/api.js b/client/public/js/api.js new file mode 100644 index 0000000..1250cd3 --- /dev/null +++ b/client/public/js/api.js @@ -0,0 +1,82 @@ +/** + * payNEXT Centralized API Client + * Automatically attaches JWT Bearer token from localStorage to all requests. + * Handles automatic 401 Unauthorized redirect to login. + */ + +// Centralized Fetch Interceptor +async function apiRequest(endpoint, options = {}) { + const token = localStorage.getItem('paynext_token'); + + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + // Attach Bearer token if it exists + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`/api/v1${endpoint}`, { + ...options, + headers, + }); + + if (response.status === 401) { + // Token expired or invalid, redirect to login + localStorage.removeItem('paynext_token'); + localStorage.removeItem('paynext_user'); + window.location.hash = '#/login'; + } + + return response.json(); +} + +// Dedicated Auth API endpoints +const authAPI = { + /** + * Log in user + * @param {string} email + * @param {string} password + */ + login: async (email, password) => { + return apiRequest('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + }, + + /** + * Register new user + * @param {{ fullName: string, email: string, password: string, phone?: string }} userData + */ + register: async (userData) => { + return apiRequest('/auth/register', { + method: 'POST', + body: JSON.stringify(userData), + }); + }, + + /** + * Fetch current authenticated user's profile + */ + getMe: async () => { + return apiRequest('/auth/me', { + method: 'GET', + }); + }, + + /** + * Check system health + */ + getHealth: async () => { + return apiRequest('/health', { + method: 'GET', + }); + }, +}; + +// Make available globally in the browser +window.apiRequest = apiRequest; +window.authAPI = authAPI; diff --git a/client/public/js/app.js b/client/public/js/app.js new file mode 100644 index 0000000..5a76715 --- /dev/null +++ b/client/public/js/app.js @@ -0,0 +1,365 @@ +/** + * payNEXT Single Page Application (SPA) Controller + * Routes: #/home, #/login, #/register, #/dashboard + */ + +document.addEventListener('DOMContentLoaded', () => { + initApp(); +}); + +function initApp() { + // Bind router events + window.addEventListener('hashchange', handleRoute); + window.addEventListener('auth:change', updateNav); + + // Form listeners + setupLoginForm(); + setupRegisterForm(); + setupLogoutButtons(); + setupDashboardActions(); + + // Initial render + updateNav(); + handleRoute(); +} + +/** + * Handle Hash-based SPA routing + */ +async function handleRoute() { + const hash = window.location.hash.toLowerCase() || '#/home'; + const authenticated = Auth.isAuthenticated(); + + // Route Guards + if (hash === '#/dashboard' && !authenticated) { + showAlert('Please log in to access your dashboard.', 'info'); + window.location.hash = '#/login'; + return; + } + + if ((hash === '#/login' || hash === '#/register') && authenticated) { + window.location.hash = '#/dashboard'; + return; + } + + // Hide all views + const views = document.querySelectorAll('.view-section'); + views.forEach((view) => view.classList.remove('active')); + + // Show target view + let targetViewId = 'home-view'; + if (hash === '#/login') targetViewId = 'login-view'; + else if (hash === '#/register') targetViewId = 'register-view'; + else if (hash === '#/dashboard') targetViewId = 'dashboard-view'; + else if (hash === '#/home' || hash === '#/' || !hash) targetViewId = 'home-view'; + + const targetView = document.getElementById(targetViewId); + if (targetView) { + targetView.classList.add('active'); + } + + // Route-specific actions + if (targetViewId === 'dashboard-view') { + loadDashboardData(); + } + + // Scroll to top + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +/** + * Update Navigation based on Auth State + */ +function updateNav() { + const authenticated = Auth.isAuthenticated(); + const user = Auth.getUser(); + + const guestNav = document.getElementById('nav-guest'); + const authNav = document.getElementById('nav-auth'); + const userDisplay = document.getElementById('nav-user-name'); + + if (guestNav && authNav) { + if (authenticated) { + guestNav.style.display = 'none'; + authNav.style.display = 'flex'; + if (userDisplay) { + userDisplay.textContent = user?.fullName || user?.email || 'User'; + } + } else { + guestNav.style.display = 'flex'; + authNav.style.display = 'none'; + } + } +} + +/** + * Setup Login Form Handler + */ +function setupLoginForm() { + const form = document.getElementById('login-form'); + const errorBox = document.getElementById('login-error'); + const submitBtn = document.getElementById('login-submit-btn'); + + if (!form) return; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (errorBox) { + errorBox.style.display = 'none'; + errorBox.textContent = ''; + } + + const email = document.getElementById('login-email').value.trim(); + const password = document.getElementById('login-password').value; + + if (!email || !password) { + showFormError(errorBox, 'Please enter both email and password.'); + return; + } + + // Set loading state + setButtonLoading(submitBtn, true, 'Signing in...'); + + try { + const res = await authAPI.login(email, password); + + if (res.success && res.data?.token) { + // Store token and user profile in localStorage + Auth.setSession(res.data.token, res.data.user); + form.reset(); + showAlert('Welcome back! Login successful.', 'success'); + window.location.hash = '#/dashboard'; + } else { + const errorMsg = Auth.formatError(res); + showFormError(errorBox, errorMsg); + } + } catch (err) { + showFormError(errorBox, err.message || 'Login failed. Please try again.'); + } finally { + setButtonLoading(submitBtn, false, 'Sign In'); + } + }); +} + +/** + * Setup Register Form Handler + */ +function setupRegisterForm() { + const form = document.getElementById('register-form'); + const errorBox = document.getElementById('register-error'); + const submitBtn = document.getElementById('register-submit-btn'); + + if (!form) return; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (errorBox) { + errorBox.style.display = 'none'; + errorBox.textContent = ''; + } + + const fullName = document.getElementById('register-name').value.trim(); + const email = document.getElementById('register-email').value.trim(); + const password = document.getElementById('register-password').value; + const phone = document.getElementById('register-phone').value.trim(); + + if (!fullName || !email || !password) { + showFormError(errorBox, 'Full Name, Email, and Password are required.'); + return; + } + + if (password.length < 6) { + showFormError(errorBox, 'Password must be at least 6 characters long.'); + return; + } + + // Set loading state + setButtonLoading(submitBtn, true, 'Creating Account...'); + + try { + const payload = { fullName, email, password }; + if (phone) payload.phone = phone; + + const res = await authAPI.register(payload); + + if (res.success && res.data?.token) { + // Store token, user, and optionally wallet in localStorage + Auth.setSession(res.data.token, res.data.user); + if (res.data.wallet) { + localStorage.setItem('paynext_wallet', JSON.stringify(res.data.wallet)); + } + form.reset(); + showAlert('Account created successfully! Welcome to payNEXT.', 'success'); + window.location.hash = '#/dashboard'; + } else { + const errorMsg = Auth.formatError(res); + showFormError(errorBox, errorMsg); + } + } catch (err) { + showFormError(errorBox, err.message || 'Registration failed. Please try again.'); + } finally { + setButtonLoading(submitBtn, false, 'Create Account'); + } + }); +} + +/** + * Setup Logout Button Handlers + */ +function setupLogoutButtons() { + document.querySelectorAll('.btn-logout').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + Auth.logout(); + showAlert('You have been logged out safely.', 'info'); + }); + }); +} + +/** + * Load and display Dashboard Data + */ +async function loadDashboardData() { + const cachedUser = Auth.getUser(); + const token = Auth.getToken(); + + // Populate cached data first + if (cachedUser) { + renderUserData(cachedUser); + } + + // Render Token Security status + renderTokenSecurity(token); + + // Fetch fresh profile from API /api/v1/auth/me + try { + const res = await authAPI.getMe(); + if (res.success && res.data) { + Auth.setSession(token, res.data); + renderUserData(res.data); + } + } catch (error) { + console.error('Failed to load fresh user data:', error); + } +} + +/** + * Render user profile info in dashboard + */ +function renderUserData(user) { + const nameEl = document.getElementById('dash-user-name'); + const emailEl = document.getElementById('dash-user-email'); + const roleEl = document.getElementById('dash-user-role'); + const phoneEl = document.getElementById('dash-user-phone'); + const idEl = document.getElementById('dash-user-id'); + + if (nameEl) nameEl.textContent = user.fullName || user.email || 'N/A'; + if (emailEl) emailEl.textContent = user.email || 'N/A'; + if (roleEl) roleEl.textContent = (user.role || 'user').toUpperCase(); + if (phoneEl) phoneEl.textContent = user.phone || 'None provided'; + if (idEl) idEl.textContent = user.id ? `UID-${user.id}` : 'N/A'; + + // Check stored wallet preview + const cachedWallet = localStorage.getItem('paynext_wallet'); + const walletEl = document.getElementById('dash-wallet-number'); + if (walletEl) { + if (cachedWallet) { + try { + const w = JSON.parse(cachedWallet); + walletEl.textContent = w.wallet_number || 'PAYNX-' + String(w.id || '000000').padStart(6, '0'); + } catch (e) { + walletEl.textContent = 'PAYNX-ACTIVE'; + } + } else { + walletEl.textContent = 'PAYNX-READY'; + } + } +} + +/** + * Render Token Security Verification card + */ +function renderTokenSecurity(token) { + const tokenPreviewEl = document.getElementById('dash-token-preview'); + if (tokenPreviewEl && token) { + const parts = token.split('.'); + if (parts.length === 3) { + tokenPreviewEl.textContent = `${parts[0]}.${parts[1].substring(0, 15)}...[PROTECTED_SIGNATURE]`; + } else { + tokenPreviewEl.textContent = token.substring(0, 25) + '...'; + } + } +} + +/** + * Setup Dashboard Actions + */ +function setupDashboardActions() { + const copyBtn = document.getElementById('copy-token-btn'); + if (copyBtn) { + copyBtn.addEventListener('click', () => { + const token = Auth.getToken(); + if (token) { + navigator.clipboard.writeText(token).then(() => { + showAlert('Bearer token copied to clipboard!', 'success'); + }).catch(() => { + showAlert('Could not copy token automatically.', 'warning'); + }); + } + }); + } +} + +/** + * UI Utilities + */ +function showFormError(el, message) { + if (!el) return; + el.textContent = message; + el.style.display = 'block'; + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function setButtonLoading(btn, isLoading, text) { + if (!btn) return; + btn.disabled = isLoading; + btn.innerHTML = isLoading ? ` ${text}` : text; +} + +function showAlert(message, type = 'info') { + const alertContainer = document.getElementById('global-toast-container'); + if (!alertContainer) return; + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.innerHTML = ` + ${message} + + `; + + alertContainer.appendChild(toast); + + setTimeout(() => { + toast.classList.add('fade-out'); + setTimeout(() => toast.remove(), 400); + }, 4000); +} + +// Health check integration +window.checkHealth = async function () { + const result = document.getElementById('healthResult'); + if (!result) return; + + try { + result.textContent = 'Checking services...'; + const webRes = await fetch('/healthz'); + const webText = webRes.ok ? await webRes.text() : `Failed (${webRes.status})`; + + const apiRes = await authAPI.getHealth(); + const apiStatus = apiRes.success ? 'OK (paynext-api online)' : `API error (${apiRes.error || apiRes.status || 'offline'})`; + + result.textContent = `Web Server: ${webText} | API Server: ${apiStatus}`; + } catch (error) { + result.textContent = 'Health check error: ' + error.message; + } +}; diff --git a/client/public/js/auth.js b/client/public/js/auth.js new file mode 100644 index 0000000..8b0f6bb --- /dev/null +++ b/client/public/js/auth.js @@ -0,0 +1,88 @@ +/** + * payNEXT Authentication State Manager + * Handles localStorage token management, user session caching, and auth guards. + */ + +const Auth = { + TOKEN_KEY: 'paynext_token', + USER_KEY: 'paynext_user', + + /** + * Get the current JWT token from localStorage + * @returns {string|null} + */ + getToken() { + return localStorage.getItem(this.TOKEN_KEY); + }, + + /** + * Get the cached user profile + * @returns {object|null} + */ + getUser() { + try { + const data = localStorage.getItem(this.USER_KEY); + return data ? JSON.parse(data) : null; + } catch (e) { + console.error('Failed to parse cached user:', e); + return null; + } + }, + + /** + * Store token and user profile in localStorage + * @param {string} token + * @param {object} user + */ + setSession(token, user) { + if (token) { + localStorage.setItem(this.TOKEN_KEY, token); + } + if (user) { + localStorage.setItem(this.USER_KEY, JSON.stringify(user)); + } + window.dispatchEvent(new CustomEvent('auth:change', { detail: { isAuthenticated: true, user } })); + }, + + /** + * Clear session from localStorage + */ + clearSession() { + localStorage.removeItem(this.TOKEN_KEY); + localStorage.removeItem(this.USER_KEY); + window.dispatchEvent(new CustomEvent('auth:change', { detail: { isAuthenticated: false, user: null } })); + }, + + /** + * Check if user is currently authenticated + * @returns {boolean} + */ + isAuthenticated() { + return !!this.getToken(); + }, + + /** + * Log out current user and redirect to login + */ + logout() { + this.clearSession(); + window.location.hash = '#/login'; + }, + + /** + * Parse error message from API response (supports standard string error or express-validator errors array) + * @param {object} res + * @returns {string} + */ + formatError(res) { + if (!res) return 'An unexpected error occurred.'; + if (res.error) return res.error; + if (res.message && typeof res.message === 'string') return res.message; + if (Array.isArray(res.errors) && res.errors.length > 0) { + return res.errors.map(err => err.msg || err.message || JSON.stringify(err)).join(', '); + } + return 'Operation failed. Please try again.'; + } +}; + +window.Auth = Auth;