Compare commits

..
53 Commits
Author SHA1 Message Date
eneller f708b2e84f feat(client): custom colors 2026-08-02 11:49:06 +02:00
eneller 0aea64383a refactor(client): rename screen 2026-08-02 10:47:31 +02:00
eneller 64fa5eb54d refactor: Modal in own component 2026-08-02 10:37:20 +02:00
eneller b47cc5e90e doc: use cases 2026-07-30 17:31:15 +02:00
eneller 0619428e07 feat: account switcher 2026-07-23 20:19:53 +02:00
eneller ed6c8cc413 feat(client): show real data 2026-07-23 18:49:44 +02:00
eneller e8f8cfbe99 doc: README 2026-07-23 17:33:45 +02:00
eneller cb9728a90b feat: notifications 2026-07-23 16:29:03 +02:00
eneller c282e275ef fix: delayed displaying of messages, transactions using signals 2026-07-23 15:17:29 +02:00
eneller 0f4474d29b feat(server): business owned by user data model 2026-07-23 14:28:20 +02:00
eneller c876a45ef8 feat(client): auth interceptor 2026-07-23 13:02:58 +02:00
eneller 4f734fff30 feat (server): user scopes 2026-07-23 12:11:42 +02:00
eneller a3158894c8 feat: hash passwords 2026-07-23 11:23:53 +02:00
eneller ceab2dee0a refactor: messaging 2026-06-26 13:05:10 +02:00
eneller 37fdc28e1f fix: auth guard 2026-06-26 11:50:15 +02:00
eneller 38dedfc829 fix: nav 2026-04-03 08:47:39 +02:00
eneller 4f8da032b7 feat: frontend send 2026-03-18 15:30:39 +01:00
eneller 7f3326c4a5 fix: send api 2026-03-18 10:27:26 +01:00
eneller 823d696d86 feat: api send 2026-03-18 09:18:27 +01:00
eneller c6629234e1 feat: auth middleware 2026-03-17 00:37:56 +01:00
eneller 8be35e9403 feat: jwt auth 2026-03-16 23:45:09 +01:00
eneller fa2203927a fix: save and send cookie in dev setup 2026-03-12 07:46:25 +01:00
eneller c9a2cd8d66 auth guard 2026-03-08 19:27:18 +01:00
eneller cd568f0a63 begin auth cookie 2026-03-08 19:02:08 +01:00
eneller f5ae9ac9e6 basic login/logout 2026-03-07 14:48:19 +01:00
eneller 111d1a7b48 auth frontend 2026-03-07 13:53:46 +01:00
eneller 3300622191 begin auth 2026-03-07 00:43:40 +01:00
eneller 80a260f515 fix: currency display 2026-03-06 19:53:04 +01:00
eneller 308225f190 fix: transactions display 2026-03-06 19:34:38 +01:00
eneller a618ce5e1e feat: db 2026-03-06 19:22:29 +01:00
eneller 0dabdaa136 database models 2026-03-06 11:16:22 +01:00
eneller d58b0babd2 begin sequelize 2026-03-06 10:24:47 +01:00
eneller fb0fc59240 feat: logging 2026-03-06 01:41:49 +01:00
eneller 599ed86c52 feat: basic db connection 2026-03-06 00:57:19 +01:00
eneller 4f206d9ad9 build: tsconfig shared/ 2026-03-05 23:39:42 +01:00
eneller 0dd4b6590d feat: basic api 2026-03-05 23:31:56 +01:00
eneller cef3474c3d feat: profile screen 2026-03-05 22:34:50 +01:00
eneller 427064a24c feat: receive screen qr code 2026-03-05 22:19:58 +01:00
eneller 9444145bf5 feat: receive screen 2026-03-05 21:23:16 +01:00
eneller 6af1a8a3e9 feat: send screen 2026-03-05 21:08:31 +01:00
eneller 886903e402 feat: working nav 2026-03-05 20:09:15 +01:00
eneller a6b9a5860c fix: ng build 2026-03-05 19:56:30 +01:00
eneller a704c7a4e2 fix: dependencies 2026-02-28 13:30:08 +01:00
eneller 7d73d35aa4 client: begin frontend 2026-02-28 13:20:09 +01:00
eneller 75100e689c fix: server run config 2026-02-28 10:33:20 +01:00
eneller 6b7ef0114f doc: README 2026-02-28 09:41:17 +01:00
eneller 096af359f0 build: fix tsconfig 2026-02-28 02:27:48 +01:00
eneller c4efd793e2 build: fix tsconfig 2026-02-28 01:35:01 +01:00
eneller 05090ebb14 build: fix tsconfig 2026-02-28 01:19:48 +01:00
eneller e64a77a0d7 chore: add sequelize 2026-02-28 00:35:25 +01:00
eneller 9f6c9b569c chore: add ng-bootstrap+icons, qrcode 2026-02-28 00:31:16 +01:00
eneller 40b020455e chore: angular init 2026-02-28 00:26:00 +01:00
eneller 378366e5f5 chore: backend setup 2026-02-28 00:21:44 +01:00
91 changed files with 13483 additions and 41 deletions

No files matched your search

+1 -1
View File
@@ -192,4 +192,4 @@ dist
.ionide .ionide
# End of https://www.toptal.com/developers/gitignore/api/nextjs,node,visualstudiocode # End of https://www.toptal.com/developers/gitignore/api/nextjs,node,visualstudiocode
.docker/
+21
View File
@@ -0,0 +1,21 @@
# FakeMoney
**A PayPal-like payment processor for virtual money, intended to be used for [simulation games](https://de.wikipedia.org/wiki/Schule_als_Staat).**
Send and receive money from your account and businesses you own using simple URLs and QR codes.
Install as a [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)
from your browser (Firefox/Chrome/Safari) with just 3 taps and bypass tedious app store processes.
<img src="docs/img/screenshot-login.png" width="32%" alt="Login view">&nbsp;
<img src="docs/img/screenshot-send.png" width="32%" alt="Send view">&nbsp;
<img src="docs/img/screenshot-receive.png" width="32%" alt="Receive view">&nbsp;
## Deployment
Is simplified using Docker and by making a few assumptions, e.g. that API and frontend are available under the same domain.
## Development
### Frontend
Is written in angular + bootstrap.
### Backend
Uses NextJS + PostgreSQL with JWT as cookies.
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+59
View File
@@ -0,0 +1,59 @@
# Client
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.0.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "../node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "less"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "../dist/out-tsc/client",
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "less",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"../node_modules/bootstrap/dist/css/bootstrap.min.css",
"../node_modules/bootstrap-icons/font/bootstrap-icons.min.css",
"src/styles.less"
],
"scripts": [
"../node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
],
"polyfills": ["@angular/localize/init"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"options": {
"proxyConfig": "proxy.json"
},
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "client:build:production"
},
"development": {
"buildTarget": "client:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+38 -7
View File
@@ -1,12 +1,43 @@
{ {
"name": "client", "name": "client",
"version": "1.0.0", "version": "0.0.0",
"description": "",
"license": "GPL-3.0",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "ng": "ng",
"start": "ng serve",
"serve": "ng serve --host 0.0.0.0",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.10.0",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"ng-qrcode": "^21.0.0",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.0",
"@angular/cli": "^21.2.0",
"@angular/compiler-cli": "^21.2.0",
"@angular/localize": "^21.2.0",
"@types/qrcode": "^1.5.6",
"jsdom": "^28.0.0",
"less": "^4.2.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
} }
} }
+8
View File
@@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true,
"withCredentials": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+17
View File
@@ -0,0 +1,17 @@
import { ApplicationConfig, DEFAULT_CURRENCY_CODE, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { DATE_PIPE_DEFAULT_OPTIONS } from '@angular/common';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './services/auth-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
{provide: DEFAULT_CURRENCY_CODE, useValue: ''},
{provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: {dateFormat: 'shortDate'}},
provideHttpClient(withInterceptors([authInterceptor])),
]
};
+17
View File
@@ -0,0 +1,17 @@
<main class="main">
<app-toast-container></app-toast-container>
<router-outlet></router-outlet>
</main>
<nav>
<ul ngbNav [(activeId)]="active" class="nav-tabs custom-navbar bg-body-secondary">
<li ngbNavItem="/send" class="custom-navitem"><a ngbNavLink routerLink="/send">
<i class="bi bi-cash"></i>
</a></li>
<li ngbNavItem="/receive" class="custom-navitem"><a ngbNavLink routerLink="/receive">
<i class="bi bi-piggy-bank"></i>
</a></li>
<li ngbNavItem="/profile" class="custom-navitem"><a ngbNavLink routerLink="/profile">
<i class="bi bi-person"></i>
</a></li>
</ul>
</nav>
+35
View File
@@ -0,0 +1,35 @@
html, body{
max-width: 100%;
overflow-x: hidden;
}
*{
text-align: center;
}
.custom-table {
//table-layout: fixed;
max-width: 100%;
}
tr{
padding-right: .2em;
}
.wrap-cell{
word-break: break-all;
white-space: normal;
}
.main{
margin-bottom: 5rem;
padding: 1rem;
overflow-y: auto;
}
.custom-navbar{
width: 100vw;
position: fixed;
bottom: 0;
display: flex;
justify-content: space-around; /* Distribute items evenly */
z-index: 1000;
}
.custom-navitem{
text-align: center; /* Center the content of each item */
flex: 1;
}
+33
View File
@@ -0,0 +1,33 @@
import { Routes } from '@angular/router';
import { ScreenSend } from './screens/screen-send/screen-send';
import { ScreenRequest } from './screens/screen-request/screen-request';
import { ScreenProfile } from './screens/screen-profile/screen-profile';
import { ScreenLogin } from './screens/screen-login/screen-login';
import { authGuard } from './services/auth-guard';
export const routes: Routes = [
{
path: '',
pathMatch:'full',
redirectTo: '/send',
},
{
path: 'login',
component: ScreenLogin,
},
{
path:'send',
component: ScreenSend,
canActivate: [authGuard],
},
{
path:'receive',
component: ScreenRequest,
canActivate: [authGuard],
},
{
path:'profile',
component: ScreenProfile,
canActivate: [authGuard],
},
];
+23
View File
@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, client');
});
});
+39
View File
@@ -0,0 +1,39 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterOutlet, RouterLinkWithHref, Router, NavigationEnd } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {
NgbNav,
NgbNavItem,
NgbNavItemRole,
NgbNavLinkBase,
} from '@ng-bootstrap/ng-bootstrap/nav';
import { filter } from 'rxjs';
import { APIService } from './services/api';
import { ToastContainer } from "./components/toast-container/toast-container";
@Component({
selector: 'app-root',
imports: [RouterOutlet, NgbModule, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLinkBase, RouterLinkWithHref, ToastContainer],
templateUrl: './app.html',
styleUrl: './app.less'
})
export class App implements OnInit{
protected readonly title = signal('client');
protected api = inject(APIService);
active = '/';
constructor(
private router: Router
){}
ngOnInit(): void {
this.api.checkAuthStatus().subscribe();
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() =>{
this.active = this.router.url;
console.log(this.active);
});
}
}
@@ -0,0 +1,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="activeModal.dismiss()"></button>
</div>
<div class="modal-body text-center">
<ng-container *ngTemplateOutlet="body"></ng-container>
</div>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Modal } from './modal';
describe('Modal', () => {
let component: Modal;
let fixture: ComponentFixture<Modal>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Modal],
}).compileComponents();
fixture = TestBed.createComponent(Modal);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, Input, TemplateRef } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'app-modal',
imports: [NgTemplateOutlet],
templateUrl: './modal.html',
styleUrl: './modal.less',
})
export class Modal {
@Input({required: true}) title!: string;
@Input({required: true}) body!: TemplateRef<any>;
constructor(
public activeModal: NgbActiveModal
){}
}
@@ -0,0 +1,13 @@
<div class="toast-container position-fixed top-0 end-0 p-3">
@for (notification of notifications.notifications(); track notification) {
<ngb-toast
[autohide]="true"
[delay]="notification.delay ?? 3000"
(hidden)="notifications.remove(notification)"
>
<div class="text-{{ notification.type }}">
{{ notification.message }}
</div>
</ngb-toast>
}
</div>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToastContainer } from './toast-container';
describe('ToastContainer', () => {
let component: ToastContainer;
let fixture: ComponentFixture<ToastContainer>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastContainer],
}).compileComponents();
fixture = TestBed.createComponent(ToastContainer);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,13 @@
import { Component, inject } from '@angular/core';
import { NgbToastModule } from '@ng-bootstrap/ng-bootstrap';
import { NotificationService } from '../../services/notification';
@Component({
selector: 'app-toast-container',
imports: [NgbToastModule],
templateUrl: './toast-container.html',
styleUrl: './toast-container.less',
})
export class ToastContainer {
notifications = inject(NotificationService);
}
@@ -0,0 +1,77 @@
<div class="d-flex align-items-center min-vh-100 bg-light">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-4 col-md-6 col-sm-8">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-lock-fill fs-1 text-primary"></i>
<h3 class="mt-2">Sign In</h3>
</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" novalidate>
<!-- Email -->
<div class="mb-3">
<label for="email" class="form-label">Username</label>
<input
type="text"
id="username"
class="form-control"
formControlName="username"
placeholder="Enter your username"
>
</div>
<!-- Password -->
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<input
[type]="showPassword ? 'text' : 'password'"
id="password"
class="form-control"
formControlName="password"
placeholder="Enter your password"
>
<button
class="btn btn-outline-secondary"
type="button"
(click)="showPassword = !showPassword"
>
<i class="bi" [class.bi-eye-fill]="showPassword" [class.bi-eye-slash-fill]="!showPassword"></i>
</button>
</div>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn btn-primary w-100 mb-3"
>
@if (loading()) {
<span> Signing In... </span>
}@else {
<span>Sign In</span>
}
</button>
<!-- Error Alert -->
@if (error()) {
<ngb-alert
type="danger"
(closed)="error.set(null)"
[dismissible]="true"
>
{{ error() }}
</ngb-alert>
}
</form>
</div>
</div>
</div>
</div>
</div>
</div>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenLogin } from './screen-login';
describe('ScreenLogin', () => {
let component: ScreenLogin;
let fixture: ComponentFixture<ScreenLogin>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenLogin],
}).compileComponents();
fixture = TestBed.createComponent(ScreenLogin);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,54 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import { Validators, FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Form } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { APIService } from '../../services/api';
import { GenericMessage } from '@message/Message';
import { NotificationService } from '../../services/notification';
@Component({
selector: 'app-screen-login',
imports: [FormsModule, NgbModule, ReactiveFormsModule, CommonModule],
templateUrl: './screen-login.html',
styleUrl: './screen-login.less',
})
export class ScreenLogin {
loginForm: FormGroup;
submitted = false;
loading = signal(false);
showPassword = false;
error = signal<string|null>(null);
constructor(
private api: APIService,
private notify: NotificationService,
private router: Router,
private route: ActivatedRoute,
private fb: FormBuilder,
) {
this.loginForm = this.fb.group({
username: ['', [Validators.required]],
password: ['', [Validators.required]],
});
}
onSubmit() {
this.submitted = true;
this.error.set(null);
this.loading.set(true)
this.api.login(this.loginForm.value.username, this.loginForm.value.password).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl);
},
error: (resp) => {
let msg: GenericMessage = resp.error;
this.notify.error(msg.message || 'Login failed. Please try again.');
this.loading.set(false);
}
});
}
}
@@ -0,0 +1,71 @@
<div class="container py-4">
<!-- Profile Header -->
<div class="card mb-4 shadow-sm">
<div class="card-body text-center p-4">
<div class="avatar avatar-xl mb-3">
<i class="bi bi-person-circle fs-1 text-primary"></i>
<b> {{this.api.loggedInUser.displayName}} </b>
</div>
<div ngbDropdown class="d-inline-block">
<button type="button" class="btn btn-outline-primary" id="dropdownBasic1" ngbDropdownToggle>
{{ this.api.currentUser.displayName }}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
@for (acc of this.api.ownedAccounts; track $index) {
<button ngbDropdownItem (click)="this.api.currentUser = acc">{{ acc.displayName }}</button>
}
</div>
</div>
<h3 class="mb-1"></h3>
</div>
<div class="d-flex align-items-center justify-content-center gap-2 mb-1">
<i class="bi bi-wallet2 fs-4"></i>
<h3 class="mb-0">{{ this.api.currentUser.balance | currency}}</h3>
</div>
<button type="button" (click)="logOut()" class="btn btn-outline-secondary">Log out</button>
</div>
</div>
<!-- Recent Transactions -->
<div class="card mb-4 shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Recent Transactions</h5>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
<!-- Transaction Item -->
@for (transaction of transactions(); track $index) {
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="me-3">
<i class="bi bi-person-fill fs-4 text-secondary"></i>
</div>
<div class="text-start">
@if (transaction.receiverID == this.api.currentUser.id) {
<h6 class="mb-0">{{ transaction.senderID }}</h6>
}@else {
<h6 class="mb-0">{{ transaction.receiverID }}</h6>
}
<small class="text-muted">{{ transaction.date | date:'medium' }}</small>
</div>
</div>
<div class="text-end">
@if (transaction.receiverID == this.api.currentUser.id) {
<h6 class="mb-0 text-success">
{{ transaction.amount | currency }}
</h6>
}@else {
<h6 class="mb-0 text-danger">
{{ - transaction.amount | currency }}
</h6>
}
</div>
</div>
</div>
}
@empty {<p class="text-muted">No transactions yet</p>}
</div>
</div>
</div>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenProfile } from './screen-profile';
describe('ScreenProfile', () => {
let component: ScreenProfile;
let fixture: ComponentFixture<ScreenProfile>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenProfile],
}).compileComponents();
fixture = TestBed.createComponent(ScreenProfile);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,43 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, inject, OnInit, signal } from '@angular/core';
import { APIService } from '../../services/api';
import Transaction from '@model/transaction';
import { Router } from '@angular/router';
import { NgbAccordionToggle, NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-screen-profile',
imports: [CurrencyPipe, DatePipe, CommonModule, NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbDropdownItem],
templateUrl: './screen-profile.html',
styleUrl: './screen-profile.less',
})
export class ScreenProfile implements OnInit{
transactions = signal<Transaction[]>([])
constructor(
protected api: APIService,
private router: Router,
){}
ngOnInit(): void {
this.api.getTransactions().subscribe({
next: (transactions) => {
this.transactions.set(transactions);
},
error: (err) => {
console.error('Error fetching transactions:', err);
},
})
}
logOut(){
this.api.logout().subscribe({
next: () => {
this.router.navigate(['/'])
},
error: (err) => {
console.error('Error logging out:', err)
}
})
}
}
@@ -0,0 +1,57 @@
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h3 class="mb-0">Receive Money</h3>
</div>
<div class="card-body p-4 text-center">
<!-- Large Amount Display -->
<div class="mb-4">
<label class="form-label h5">Amount to Receive</label>
<div class="input-group input-group-lg mb-3">
<span class="input-group-text"><i class="bi bi-wallet2"></i></span>
<input
type="number"
class="form-control text-center fs-2"
placeholder="0.00"
[(ngModel)]="amount"
/>
</div>
</div>
<!-- Shareable Link -->
<div class="mb-4">
<p class="text-muted">Share this link to receive money:</p>
<div class="input-group mb-3">
<input
type="text"
class="form-control"
[value]="shareableLink"
readonly
/>
<button class="btn btn-outline-secondary" (click)="copyLink()">
Copy
</button>
</div>
</div>
<!-- Share Button -->
<ng-template #qrTemplate>
<qr-code [value]="shareableLink"
size="300"
errorCorrectionLevel="M" />
</ng-template>
<div class="d-grid gap-2">
<button class="btn btn-primary btn-lg" (click)="openModal()">
<i class="bi bi-qr-code-scan me-2"></i> Show QR Code
</button>
</div>
</div>
</div>
</div>
</div>
</div>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenRequest } from './screen-request';
describe('ScreenReceive', () => {
let component: ScreenRequest;
let fixture: ComponentFixture<ScreenRequest>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenRequest],
}).compileComponents();
fixture = TestBed.createComponent(ScreenRequest);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,33 @@
import { Component, inject, TemplateRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { QrCodeComponent } from 'ng-qrcode';
import { APIService } from '../../services/api';
import { Modal } from '../../components/modal/modal';
@Component({
selector: 'app-screen-request',
imports: [FormsModule, QrCodeComponent],
templateUrl: './screen-request.html',
styleUrl: './screen-request.less',
})
export class ScreenRequest {
private modalService = inject(NgbModal);
api = inject(APIService);
@ViewChild('qrTemplate') qrTemplate !: TemplateRef<any>;
amount: number = 0;
get shareableLink(): string {
const currentDomain = window.location.origin;
return `${currentDomain}/send/${this.api.currentUser.id}?amount=${this.amount}`;
}
copyLink() {
navigator.clipboard.writeText(this.shareableLink);
}
openModal() {
const modalRef = this.modalService.open(Modal);
modalRef.componentInstance.title = `Pay ${this.amount} to ${this.api.currentUser.id}`
modalRef.componentInstance.body = this.qrTemplate;
}
}
@@ -0,0 +1,62 @@
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Send Money</h3>
</div>
<div class="card-body p-4">
<!-- Amount Input (Centered Large Field) -->
<div class="text-center mb-4">
<label class="form-label h5">Amount</label>
<div class="input-group input-group-lg mb-3">
<span class="input-group-text"><i class="bi bi-wallet2"></i></span>
<input
type="number"
class="form-control text-center fs-2"
placeholder="0.00"
[(ngModel)]="amount"
/>
</div>
</div>
<!-- Recipient Field -->
<div class="mb-3">
<label class="form-label">To</label>
<div class="input-group">
<span class="input-group-text">@</span>
<input
type="text"
class="form-control"
placeholder="username"
[(ngModel)]="recipient"
/>
<button (click)="openScanner()" class="btn btn-primary"><i class="bi bi-qr-code-scan"></i></button>
</div>
</div>
<!-- Note Field -->
<div class="mb-4">
<label class="form-label">Note (Optional)</label>
<textarea class="form-control" rows="2" [(ngModel)]="reference"></textarea>
</div>
<!-- Action Buttons -->
<div class="d-grid gap-2">
<button class="btn btn-primary btn-lg" (click)="sendMoney()">
Send Money
</button>
<button class="btn btn-outline-secondary btn-lg" (click)="clear()">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<ng-template #qrScanner>
Abc
</ng-template>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenSend } from './screen-send';
describe('ScreenSend', () => {
let component: ScreenSend;
let fixture: ComponentFixture<ScreenSend>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenSend],
}).compileComponents();
fixture = TestBed.createComponent(ScreenSend);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,59 @@
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { APIService } from '../../services/api';
import { NotificationService } from '../../services/notification';
import QrScanner from 'qr-scanner';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Modal } from '../../components/modal/modal';
@Component({
selector: 'app-screen-send',
imports: [FormsModule],
templateUrl: './screen-send.html',
styleUrl: './screen-send.less',
})
export class ScreenSend {
amount: number = 0;
recipient: string = '';
reference: string = '';
scanner!: QrScanner;
@ViewChild('qrScanner') templScanner!: TemplateRef<any>;
constructor(
private api: APIService,
private notify: NotificationService,
private modalService: NgbModal,
){}
sendMoney() {
this.api.send(this.amount, this.recipient, this.reference).subscribe({
next:()=> {
this.notify.success(`Sent ${this.amount} to ${this.recipient}`);
this.clear()
},
error:(err)=> {
if(err.status == 404){
this.notify.error(`Invalid recipient "${this.recipient}"`);
}
else if(err.status == 402){
this.notify.error(`Insufficient funds.`);
}
else{
this.notify.error(`An error occurred during payment: ${err.status}`);
}
}
});
}
clear() {
this.amount = 0;
this.recipient = '';
this.reference = '';
}
openScanner(){
const modalRef = this.modalService.open(Modal);
modalRef.componentInstance.title = 'Scan a QR Code';
modalRef.componentInstance.body = this.templScanner;
}
closeScanner(){}
}
+16
View File
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { APIService } from './api';
describe('Api', () => {
let service: APIService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(APIService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BehaviorSubject, catchError, map, Observable, of, tap } from 'rxjs';
import Transaction from '@model/transaction'
import { SendRequest, SendResponse } from '@message/Send';
import { LoginResponse } from '@message/Login';
import Account from '@model/user';
@Injectable({
providedIn: 'root',
})
export class APIService {
private apiUrl = '/api';
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
// data holding
loggedInUser!: Account;
currentUser!: Account;
ownedAccounts!: Account[];
constructor(private http: HttpClient){}
login(username: string, password: string): Observable<any>{
return this.http.post<LoginResponse>(`${this.apiUrl}/auth/login`,{ 'username': username, 'password': password}).pipe(
tap({
next: (resp) => {
this.isAuthenticatedSubject.next(true);
this.loggedInUser = resp.user;
this.currentUser = this.loggedInUser;
this.ownedAccounts = resp.ownedAccounts;
this.ownedAccounts.push(this.loggedInUser);
},
error: () => this.isAuthenticatedSubject.next(false)
})
);
}
logout(): Observable<any>{
return this.http.post(`${this.apiUrl}/auth/logout`, {}).pipe(
tap({
next: () => this.isAuthenticatedSubject.next(false),
error: () => this.isAuthenticatedSubject.next(false),
})
);
}
clearAuthState() {
this.isAuthenticatedSubject.next(false);
}
checkAuthStatus(): Observable<boolean> {
return this.http.get(`${this.apiUrl}/auth/status`).pipe(
map(() => true),
catchError(() => of(false)),
tap( authenticated => {
this.isAuthenticatedSubject.next(authenticated);
})
);
}
getTransactions(): Observable<Transaction[]>{
return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`);
}
send(amount: number, recipientID: string, reference: string = ""): Observable<SendResponse>{
let request: SendRequest = {senderID: this.currentUser.id, amount, recipientID, reference};
return this.http.post<SendResponse>(`${this.apiUrl}/send`, request);
}
}
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { authGuard } from './auth-guard';
describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});
+21
View File
@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { APIService } from './api';
import { map, take } from 'rxjs/operators';
export const authGuard: CanActivateFn = (route, state) => {
const api = inject(APIService);
const router = inject(Router);
return api.isAuthenticated$.pipe(
take(1),
map((isAuthenticated) => {
if (isAuthenticated) {
return true;
} else {
router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
return false;
}
})
);
};
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { authInterceptor } from './auth-interceptor';
describe('authInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => authInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});
@@ -0,0 +1,19 @@
import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
import { APIService } from './api';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const api = inject(APIService);
const router = inject(Router)
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401){
api.clearAuthState();
router.createUrlTree(['/login']);
}
return throwError(() => err);
})
)
};
+16
View File
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from './data';
describe('Data', () => {
let service: DataService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(DataService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+6
View File
@@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class DataService {}
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { NotificationService } from './notification';
describe('Notification', () => {
let service: NotificationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(NotificationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class NotificationService {
notifications = signal<Notification[]>([]);
show(notification: Notification) {
this.notifications.update(items => [
...items,
notification,
]);
}
remove(notification: Notification) {
this.notifications.update(items =>
items.filter(item => item !== notification)
);
}
success(message: string) {
this.show({ type: 'success', message});
}
error(message: string) {
this.show({ type: 'danger', message});
}
warn(message: string) {
this.show({ type: 'warning', message});
}
info(message: string) {
this.show({ type: 'info', message});
}
}
export interface Notification {
type: 'success' | 'warning' | 'info' | 'danger';
message: string;
delay?: number;
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Client</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="@angular/localize" />
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
+67
View File
@@ -0,0 +1,67 @@
:root {
--bs-primary: #2f745f;
--bs-primary-rgb: 47, 116, 95;
--bs-primary-bg-subtle: #9dbbb2;
--bs-primary-border-subtle: #9dbbb2;
--bs-primary-text-emphasis: #114334;
--bs-link-color: #2f745f;
--bs-link-color-rgb: 47, 116, 95;
--bs-link-hover-color: #114334;
--bs-success: #114334;
--bs-success-rgb: 17, 67, 52;
--bs-success-bg-subtle: #9dbbb2;
--bs-success-border-subtle: #9dbbb2;
--bs-success-text-emphasis: #114334;
}
.btn-primary {
--bs-btn-bg: #2f745f;
--bs-btn-border-color: #2f745f;
--bs-btn-hover-bg: #114334;
--bs-btn-hover-border-color: #114334;
--bs-btn-focus-shadow-rgb: 47, 116, 95;
--bs-btn-active-bg: #114334;
--bs-btn-active-border-color: #114334;
--bs-btn-disabled-bg: #2f745f;
--bs-btn-disabled-border-color: #2f745f;
}
.btn-outline-primary {
--bs-btn-color: #2f745f;
--bs-btn-border-color: #2f745f;
--bs-btn-hover-bg: #2f745f;
--bs-btn-hover-border-color: #2f745f;
--bs-btn-focus-shadow-rgb: 47, 116, 95;
--bs-btn-active-bg: #114334;
--bs-btn-active-border-color: #114334;
--bs-btn-disabled-color: #2f745f;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #2f745f;
}
.btn-success {
--bs-btn-bg: #114334;
--bs-btn-border-color: #114334;
--bs-btn-hover-bg: #0e392c;
--bs-btn-hover-border-color: #0e392c;
--bs-btn-focus-shadow-rgb: 17, 67, 52;
--bs-btn-active-bg: #0e392c;
--bs-btn-active-border-color: #0e392c;
--bs-btn-disabled-bg: #114334;
--bs-btn-disabled-border-color: #114334;
}
.nav-pills .nav-link.active {
--bs-nav-pills-link-active-bg: #2f745f;
}
.dropdown-menu {
--bs-dropdown-link-active-bg: #2f745f;
}
.form-control:focus,
.form-select:focus {
border-color: #9dbbb2;
box-shadow: 0 0 0 0.25rem rgba(47, 116, 95, 0.25);
}
+11
View File
@@ -0,0 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc/client",
"types": ["@angular/localize"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "../tsconfig.json",
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"importHelpers": true,
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc/spec",
"types": ["vitest/globals", "@angular/localize"]
},
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
# Use Cases
## Pay someone
The client pays a business or peer by scanning their QR code or clicking their URL.
They are then shown the payment (send) screen that already contains the prefilled recipient and currency amount.
The client then confirms payment with a button and optional confirmation dialog.
## Request payment
The business requests payment from a client by sharing their URL / QR code.
The client proceeds with the `Pay someone` flow.
##
## Visitor
Visitors obtain access by receiving credentials of a predefined visitor account.
Upon login, they participate freely in the payment process as described by `Pay someone` and `Request payment`.
## Onboarding users
An administrator performs onboarding using a batch import feature that makes use of `.csv` or a similarly simple format.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+11345 -12
View File
File diff suppressed because it is too large. Load diff
+14 -5
View File
@@ -5,13 +5,22 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"author": "", "author": "",
"type": "commonjs", "type": "commonjs",
"main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1",
"server": "npm run dev --workspace=server",
"client": "npm start --prefix client",
"dev": "concurrently \"npm run server\" \"npm run client\"",
"teardown": "npm run teardown --workspace=server"
}, },
"workspaces": [ "workspaces": [
"client", "client",
"server", "server"
"shared" ],
] "devDependencies": {
"@types/node": "^25.3.2",
"concurrently": "^9.2.1",
"nodemon": "^3.1.14",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
} }
+7
View File
@@ -0,0 +1,7 @@
FM_PRIVATE_KEY='{"kty":"oct","k":"WoX65SSouJ4c8l_OxwWspo9H5dhPBq9sW0lsOJB6Ygc"}'
NODE_ENV='dev'
FM_DB_HOST
FM_DB_PORT
FM_DB_NAME
FM_DB_USER
FM_DB_PASSWORD
+19
View File
@@ -0,0 +1,19 @@
services:
postgres:
image: postgres
environment:
POSTGRES_PASSWORD: pass
ports:
- "5432:5432"
volumes:
- ./.docker:/var/lib/postgresql
adminer:
image: adminer
restart: always
environment:
ADMINER_DEFAULT_SERVER: postgres
ports:
- "8080:8080"
depends_on:
- postgres
+6
View File
@@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": "ts,json",
"exec": "ts-node src/index.ts"
}
+42 -3
View File
@@ -5,9 +5,48 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"author": "", "author": "",
"type": "commonjs", "type": "commonjs",
"main": "index.js", "main": "../dist/out-tsc/server/index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1",
"node": "nodemon",
"start": "ts-node src/index.ts",
"build": "tsc",
"serve": "node ../dist/out-tsc/server/index.js",
"setup": "docker compose up -d",
"teardown": "docker compose down",
"keygen": "ts-node src/scripts/keygen.ts",
"hash": "ts-node src/scripts/hash.ts",
"verify-hash": "ts-node src/scripts/verify-hash.ts",
"create-user": "ts-node src/scripts/create-user.ts",
"dev": "npm run setup && npm run node; npm run teardown"
}, },
"devDependencies": {} "dependencies": {
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jose": "^6.2.0",
"pg": "^8.20.0",
"pg-hstore": "^2.3.4",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.7",
"sequelize-typescript": "^2.1.6",
"winston": "^3.19.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.3.5",
"@types/pg": "^8.16.0",
"@types/validator": "^13.15.10",
"concurrently": "^9.2.1",
"eslint": "^10.0.2",
"nodemon": "^3.1.14",
"prettier": "^3.8.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
} }
+43
View File
@@ -0,0 +1,43 @@
import express, { Express, Request, Response } from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import transactionsRouter from './routes/transactions';
import authRouter from './routes/auth';
import sendRouter from './routes/send';
import dotenv from 'dotenv';
dotenv.config({quiet: true});
import { db, testConnection } from "./util/db";
import { logger } from "./util/logging";
import { setKeyFromEnv } from "./util/auth";
const app: Express = express();
app.use(cors());
app.use(cookieParser());
app.use(express.json());
app.get("/api/health", (req: Request, res: Response) => {
res.json({ status: "OK" });
});
app.use('/api/auth', authRouter);
app.use('/api/transactions', transactionsRouter);
app.use('/api/send', sendRouter);
const PORT: number = parseInt(process.env.FM_PORT as string) || 3000;
async function startServer() {
await testConnection();
// Sync models (use migrations in production!)
// Use { force: true } to drop and recreate tables (development only!)
await db.sync({ alter: true });
await setKeyFromEnv();
app.listen(PORT, () => {
logger.info(`🚀 Backend Server running on http://localhost:${PORT}`);
});
}
startServer().catch((err) => {
logger.error('Failed to start server:', err);
process.exit(1);
});
+13
View File
@@ -0,0 +1,13 @@
import Account from "../model/user";
export class LoginRequest{
constructor(
public username: string,
public password: string
){}
}
export class LoginResponse{
constructor(
public user: Account,
public ownedAccounts: Account[],
){}
}
+5
View File
@@ -0,0 +1,5 @@
export class GenericMessage{
constructor(
public message: string
){}
}
+14
View File
@@ -0,0 +1,14 @@
export class SendRequest{
constructor(
public senderID: string,
public recipientID: string,
public amount: number,
public reference: string
){}
}
export class SendResponse{
constructor(
public balance: number,
){}
}
+15
View File
@@ -0,0 +1,15 @@
import Transaction from "../model/transaction";
export class TransactionsRequest{
constructor(
public forId: string,
public offset = 0,
public count = 50,
){}
}
export class TransactionsResponse{
constructor(
public transactions: Transaction[],
){}
}
+11
View File
@@ -0,0 +1,11 @@
import Account from "../model/user";
export class UserRequest{
constructor(
){}
}
export class UserResponse{
constructor(
public user: Account
){}
}
+28
View File
@@ -0,0 +1,28 @@
import { Table, Column, Model, CreatedAt, ForeignKey, BelongsTo} from 'sequelize-typescript';
import Account from './user';
@Table
export default class Transaction extends Model{
@Column
declare amount: number;
@Column
declare reference: string;
@Column
@ForeignKey(()=> Account)
declare senderID: string;
@BelongsTo(() => Account, 'senderID')
declare sender: Account;
@Column
@ForeignKey(()=> Account)
declare receiverID: string;
@BelongsTo(() => Account, 'receiverID')
declare receiver: Account;
@CreatedAt
declare date: Date;
}
+65
View File
@@ -0,0 +1,65 @@
import { Table, Column, Model, DataType, Scopes, DefaultScope, DeletedAt, BelongsToMany, ForeignKey} from 'sequelize-typescript';
@Table
@DefaultScope(() => ({
attributes:{ exclude: ['password']}
}))
@Scopes(() => ({
withPassword: {
attributes: {include: ['password']}
}
}))
export default class Account extends Model{
@Column({primaryKey: true, unique: true, allowNull: false})
declare id: string;
@Column
declare isBusiness: boolean;
@Column
declare displayName: string;
@Column(DataType.DECIMAL(20,2))
declare balance: number;
@Column
declare password: string;
@DeletedAt
declare deletedAt: Date | null;
@BelongsToMany(() => Account, () => BusinessOwnership, 'ownerAccountId', 'ownedAccountId')
declare ownedBusinesses: Account[];
@BelongsToMany(() => Account, () => BusinessOwnership, 'ownedAccountId', 'ownerAccountId')
declare owners: Account[];
override toJSON() {
const values = { ...this.get() };
delete values.password;
return values;
}
}
@Table
export class BusinessOwnership extends Model {
@ForeignKey(() => Account)
@Column
declare ownerAccountId: string;
@ForeignKey(() => Account)
@Column
declare ownedAccountId: string;
@DeletedAt
declare deletedAt: Date | null;
}
export async function getOwnedAccounts(user: Account){
let q = await Account.findByPk(user.id, {
include: ['ownedBusinesses'],
});
let ownedAccounts = q?.ownedBusinesses;
return ownedAccounts ?? [];
}
+44
View File
@@ -0,0 +1,44 @@
import { compare } from 'bcrypt';
import express from 'express';
import { logger } from '../util/logging';
import Account, { getOwnedAccounts } from '../model/user';
import { Scope } from '../util/db';
import { getJWT, requireAuth } from '../util/auth';
import { LoginRequest, LoginResponse } from '../messages/Login';
import { GenericMessage as Msg } from '../messages/Message';
const router = express.Router();
router.post('/login', async (req, res) => {
try {
const data : LoginRequest = req.body;
const user = await Account.scope(Scope.withPassword).findOne({where: { id: data.username}});
if (!user) return res.status(401).json(new Msg('Invalid credentials'));
const isMatch = await compare(data.password, user.password);
if (!isMatch) return res.status(401).json(new Msg('Invalid credentials'));
// successfully authenticated
let jwt = await getJWT(user);
res.cookie('jwt', jwt, {
httpOnly: true, // Prevent XSS
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 86400000, // 1 day
});
res.json(new LoginResponse(user, await getOwnedAccounts(user)));
}catch (err) {
logger.error('Failed to authenticate:', err);
res.status(500).json(new Msg('Failed to authenticate'));
}
});
router.post('/logout', (req, res) => {
res.clearCookie('jwt');
res.json({ message: 'Logged out successfully' });
});
router.get('/status',requireAuth , async (req, res) => {
return res.status(200).json({authenticated: true});
})
export default router;
+56
View File
@@ -0,0 +1,56 @@
import express from 'express';
import { logger } from '../util/logging';
import { requireAuth } from '../util/auth';
import Account, { BusinessOwnership, getOwnedAccounts } from '../model/user';
import { db } from '../util/db';
import Transaction from '../model/transaction';
import { SendRequest, SendResponse} from '../messages/Send';
import { GenericMessage as Err} from '../messages/Message';
const router = express.Router();
router.post('/', requireAuth, async (req, res) => {
try {
const user = res.locals.user as Account;
const data : SendRequest = req.body;
if ( Number(data.amount) <= 0) {
return res.status(400).json(new Err('Invalid transfer amount'));
}
const recipient = await Account.findOne({where: {id: data.recipientID}})
if (!recipient) {
return res.status(404).json(new Err('Recipient not found' ));
}
const sender = await Account.findOne({where: {id: data.senderID}})
if (!sender) {
return res.status(404).json(new Err('Sender not found' ));
}
let ownsAccount = (await getOwnedAccounts(user))?.some(
business => business.id == data.senderID
);
if(!(user.equals(sender) || ownsAccount )){
return res.status(403).json(new Err('Unauthorized sender'));
}
if (Number(sender.balance) < Number(data.amount)){
logger.debug(`Insufficient balance: ${sender.balance} < ${data.amount}`)
return res.status(402).json(new Err('Insufficient balance'))
}
await db.transaction(async (t) =>{
await sender.decrement({balance: data.amount});
await recipient.increment({balance: data.amount});
await Transaction.create({
amount: data.amount,
senderID: sender.id,
receiverID: recipient.id,
reference: data.reference
});
})
return res.status(200).json(new SendResponse(sender.balance))
} catch (err) {
logger.error('Failed to commit transaction:', err);
return res.status(500).json(new Err('Failed to commit transaction' ));
}
});
export default router;
+21
View File
@@ -0,0 +1,21 @@
import express from 'express';
import { logger } from '../util/logging';
import Transaction from '../model/transaction';
import { requireAuth } from '../util/auth';
const router = express.Router();
router.get('/', requireAuth, async (req, res) => {
try {
const transactions = await Transaction.findAll({
limit: 100,
order: [['date', 'DESC']]
});
res.json(transactions);
} catch (err) {
logger.error('Failed to fetch transactions:', err);
res.status(500).json({ error: 'Failed to fetch transactions' });
}
});
export default router;
+12
View File
@@ -0,0 +1,12 @@
import { db, testConnection } from "../util/db";
import { hash } from "bcrypt";
import Account from '../model/user';
(async () =>{
await testConnection();
await Account.create({
id: process.argv[2],
displayName: process.argv[3],
balance: process.argv[4],
password: await hash(process.argv[5], 10),
})
})();
+5
View File
@@ -0,0 +1,5 @@
import {hash} from 'bcrypt';
let pass = process.argv[2];
hash(pass, 10, function(err, hash) {
console.log(hash);
});
+11
View File
@@ -0,0 +1,11 @@
import { exportJWK, generateSecret } from "jose"
async function printSecret(secret){
const jwk = await exportJWK(secret);
console.log('🔑Generated Secret:');
console.log(JSON.stringify(jwk));
}
(async () => {
const secret = await generateSecret('HS256', {extractable: true});
await printSecret(secret);
})();
+6
View File
@@ -0,0 +1,6 @@
import {compare} from 'bcrypt';
let pass = process.argv[2];
let hash = process.argv[3];
compare(pass,hash, function(err, result){
console.log(result);
});
+53
View File
@@ -0,0 +1,53 @@
import { NextFunction, Request, Response } from "express";
import Account from "../model/user"
import { importJWK, SignJWT, jwtVerify } from "jose";
let key;
async function setKeyFromEnv() {
key = await importJWK(JSON.parse(process.env.FM_PRIVATE_KEY));
}
async function getJWT(user: Account){
let jwt = await new SignJWT()
.setSubject(user.id)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.sign(key);
return jwt
}
async function requireAuth(req: Request, res: Response, next: NextFunction) {
try {
const token = req.cookies.jwt; // Or req.headers.authorization
if (!token) {
return res.status(401).json({ error: 'Unauthorized: No token provided' });
}
const jwt= await jwtVerify(token, key);
const user = await Account.findOne({where: { id: jwt.payload.sub}});
if (!user) {
return res.status(401).json({ error: 'Unauthorized: User not found' });
}
//TODO extend req instead of using res.locals
res.locals.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
}
}
function requireRole(role: string) {
return async (req: Request, res: Response, next: NextFunction) => {
// First, run requireAuth to ensure the user is authenticated
await requireAuth(req, res, () => {
if (res.locals.role !== role) {
return res.status(403).json({ error: 'Forbidden: Insufficient permissions' });
}
next(); // User is authenticated and has the required role
});
};
}
export {getJWT, setKeyFromEnv, requireAuth}
+36
View File
@@ -0,0 +1,36 @@
import { Sequelize } from 'sequelize-typescript';
import { logger } from './logging';
import Account, { BusinessOwnership } from '../model/user';
import Transaction from '../model/transaction';
enum Scope{
// for User
withPassword = 'withPassword',
}
// Initialize Sequelize
const db = new Sequelize({
dialect: 'postgres',
host: process.env.FM_DB_HOST || 'localhost',
port: parseInt(process.env.FM_DB_PORT || '5432'),
database: process.env.FM_DB_NAME || 'postgres',
username: process.env.FM_DB_USER || 'postgres',
password: process.env.FM_DB_PASSWORD || 'pass',
logging: logger.debug.bind(logger),
});
db.addModels([Account, BusinessOwnership, Transaction ])
// Test the connection
async function testConnection() {
try {
await db.authenticate();
logger.info('✅ Database connection established.');
} catch (err) {
logger.error('❌ Unable to connect to the database:', err);
process.exit(1); // Exit if DB connection fails
}
}
// Export Sequelize instance and models
export { logger, db, testConnection, Scope };
+17
View File
@@ -0,0 +1,17 @@
import winston, { format } from "winston";
const logger = winston.createLogger({
level:'info',
})
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: format.combine(
format.colorize({all: true}),
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.printf((info) => `${info.timestamp} [${info.level}]: ${info.message}`),
)
}));
}
// Export Sequelize instance and models
export { logger };
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc/server",
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "nodenext",
"moduleResolution": "nodenext"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
-13
View File
@@ -1,13 +0,0 @@
{
"name": "shared",
"version": "1.0.0",
"description": "",
"license": "GPL-3.0",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {}
}
+18
View File
@@ -0,0 +1,18 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
"moduleResolution": "bundler",
"target": "ES2022",
"module": "es2022",
"lib": ["es2022", "dom"],
"paths": {
"@model/*": ["server/src/model/*"],
"@message/*": ["server/src/messages/*"],
}
},
"exclude": ["node_modules"]
}