Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Caddyfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
paynextt.me, www.paynextt.me {
paynextt.me, www.paynextt.me, localhost {

encode zstd gzip

Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ADMIN_TOKEN>`
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.
37 changes: 37 additions & 0 deletions client/js/api.js
Original file line number Diff line number Diff line change
@@ -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;
}
69 changes: 69 additions & 0 deletions client/js/app.js
Original file line number Diff line number Diff line change
@@ -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();
56 changes: 56 additions & 0 deletions client/js/auth.js
Original file line number Diff line number Diff line change
@@ -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");
},
};
43 changes: 43 additions & 0 deletions client/js/views/dashboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@

function renderDashboard() {
return `
<section class="card">
<h1>Your Dashboard</h1>
<p class="muted">You are viewing a protected page.</p>

<div id="dashboard-message" aria-live="polite">
Loading your account...
</div>

<div id="dashboard-profile"></div>
</section>
`;
}

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";
}
}
60 changes: 60 additions & 0 deletions client/js/views/login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@

function renderLogin() {
return `
<section class="card">
<h1>Welcome back</h1>
<p class="muted">Log in to access your account.</p>

<div id="login-message" aria-live="polite"></div>

<form id="login-form">
<div class="form-group">
<label for="login-email">Email</label>
<input id="login-email" name="email" type="email"
autocomplete="email" required>
</div>

<div class="form-group">
<label for="login-password">Password</label>
<input id="login-password" name="password" type="password"
autocomplete="current-password" required>
</div>

<button class="button form-button" type="submit">
Log in
</button>
</form>

<p class="muted">
New here? <a href="#/register">Create an account</a>.
</p>
</section>
`;
}

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;
}
});
}
Loading