15 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
53 changed files with 696 additions and 167 deletions

View File

@@ -1,6 +1,17 @@
# FakeMoney # FakeMoney
A PayPal-like payment processor for virtual money, intended to be used for simulation games. **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 ## Development
### Frontend ### Frontend

View File

@@ -23,6 +23,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"ng-qrcode": "^21.0.0", "ng-qrcode": "^21.0.0",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"

View File

@@ -3,6 +3,8 @@ import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { DATE_PIPE_DEFAULT_OPTIONS } from '@angular/common'; 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 = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -10,5 +12,6 @@ export const appConfig: ApplicationConfig = {
provideRouter(routes), provideRouter(routes),
{provide: DEFAULT_CURRENCY_CODE, useValue: ''}, {provide: DEFAULT_CURRENCY_CODE, useValue: ''},
{provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: {dateFormat: 'shortDate'}}, {provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: {dateFormat: 'shortDate'}},
provideHttpClient(withInterceptors([authInterceptor])),
] ]
}; };

View File

@@ -1,4 +1,5 @@
<main class="main"> <main class="main">
<app-toast-container></app-toast-container>
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<nav> <nav>

View File

@@ -1,6 +1,6 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { ScreenSend } from './screens/screen-send/screen-send'; import { ScreenSend } from './screens/screen-send/screen-send';
import { ScreenReceive } from './screens/screen-receive/screen-receive'; import { ScreenRequest } from './screens/screen-request/screen-request';
import { ScreenProfile } from './screens/screen-profile/screen-profile'; import { ScreenProfile } from './screens/screen-profile/screen-profile';
import { ScreenLogin } from './screens/screen-login/screen-login'; import { ScreenLogin } from './screens/screen-login/screen-login';
import { authGuard } from './services/auth-guard'; import { authGuard } from './services/auth-guard';
@@ -22,7 +22,7 @@ export const routes: Routes = [
}, },
{ {
path:'receive', path:'receive',
component: ScreenReceive, component: ScreenRequest,
canActivate: [authGuard], canActivate: [authGuard],
}, },
{ {

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, signal } from '@angular/core'; import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterOutlet, RouterLinkWithHref, Router, NavigationEnd } from '@angular/router'; import { RouterOutlet, RouterLinkWithHref, Router, NavigationEnd } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { import {
@@ -8,15 +8,18 @@ import {
NgbNavLinkBase, NgbNavLinkBase,
} from '@ng-bootstrap/ng-bootstrap/nav'; } from '@ng-bootstrap/ng-bootstrap/nav';
import { filter } from 'rxjs'; import { filter } from 'rxjs';
import { APIService } from './services/api';
import { ToastContainer } from "./components/toast-container/toast-container";
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, NgbModule, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLinkBase, RouterLinkWithHref], imports: [RouterOutlet, NgbModule, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLinkBase, RouterLinkWithHref, ToastContainer],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.less' styleUrl: './app.less'
}) })
export class App implements OnInit{ export class App implements OnInit{
protected readonly title = signal('client'); protected readonly title = signal('client');
protected api = inject(APIService);
active = '/'; active = '/';
constructor( constructor(
@@ -24,6 +27,7 @@ export class App implements OnInit{
){} ){}
ngOnInit(): void { ngOnInit(): void {
this.api.checkAuthStatus().subscribe();
this.router.events this.router.events
.pipe(filter(event => event instanceof NavigationEnd)) .pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() =>{ .subscribe(() =>{

View File

@@ -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>

View File

@@ -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();
});
});

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
){}
}

View File

@@ -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>

View File

@@ -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();
});
});

View File

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

View File

@@ -48,7 +48,7 @@
type="submit" type="submit"
class="btn btn-primary w-100 mb-3" class="btn btn-primary w-100 mb-3"
> >
@if (loading) { @if (loading()) {
<span> Signing In... </span> <span> Signing In... </span>
}@else { }@else {
<span>Sign In</span> <span>Sign In</span>
@@ -57,15 +57,15 @@
<!-- Error Alert --> <!-- Error Alert -->
@if (error()) {
<ngb-alert <ngb-alert
*ngIf="error"
type="danger" type="danger"
(closed)="error = null" (closed)="error.set(null)"
[dismissible]="true" [dismissible]="true"
> >
{{ error }} {{ error() }}
</ngb-alert> </ngb-alert>
}
</form> </form>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,11 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component } from '@angular/core'; import { Component, signal } from '@angular/core';
import { Validators, FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Form } from '@angular/forms'; import { Validators, FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Form } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { APIService } from '../../services/api'; import { APIService } from '../../services/api';
import { GenericMessage } from '@message/Message';
import { NotificationService } from '../../services/notification';
@Component({ @Component({
selector: 'app-screen-login', selector: 'app-screen-login',
@@ -14,12 +16,13 @@ import { APIService } from '../../services/api';
export class ScreenLogin { export class ScreenLogin {
loginForm: FormGroup; loginForm: FormGroup;
submitted = false; submitted = false;
loading = false; loading = signal(false);
showPassword = false; showPassword = false;
error: string | null = null; error = signal<string|null>(null);
constructor( constructor(
private api: APIService, private api: APIService,
private notify: NotificationService,
private router: Router, private router: Router,
private route: ActivatedRoute, private route: ActivatedRoute,
private fb: FormBuilder, private fb: FormBuilder,
@@ -32,21 +35,20 @@ export class ScreenLogin {
onSubmit() { onSubmit() {
this.submitted = true; this.submitted = true;
this.error = null; this.error.set(null);
this.loading = true; this.loading.set(true)
this.api.login(this.loginForm.value.username, this.loginForm.value.password).subscribe({ this.api.login(this.loginForm.value.username, this.loginForm.value.password).subscribe({
next: () => { next: () => {
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/'; const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl); this.router.navigateByUrl(returnUrl);
}, },
error: (err) => { error: (resp) => {
//FIXME error message displaying delayed, display message from server response let msg: GenericMessage = resp.error;
this.error = err.error?.message || 'Login failed. Please try again.'; this.notify.error(msg.message || 'Login failed. Please try again.');
this.loading = false; this.loading.set(false);
} }
}); });
this.api.checkAuthStatus().subscribe();
} }
} }

View File

@@ -4,12 +4,23 @@
<div class="card-body text-center p-4"> <div class="card-body text-center p-4">
<div class="avatar avatar-xl mb-3"> <div class="avatar avatar-xl mb-3">
<i class="bi bi-person-circle fs-1 text-primary"></i> <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>
<h3 class="mb-1">{{ username }}</h3>
<p class="text-muted mb-4">{{ userID }}</p>
<div class="d-flex align-items-center justify-content-center gap-2 mb-1"> <div class="d-flex align-items-center justify-content-center gap-2 mb-1">
<i class="bi bi-wallet2 fs-4"></i> <i class="bi bi-wallet2 fs-4"></i>
<h3 class="mb-0">{{ balance | currency}}</h3> <h3 class="mb-0">{{ this.api.currentUser.balance | currency}}</h3>
</div> </div>
<button type="button" (click)="logOut()" class="btn btn-outline-secondary">Log out</button> <button type="button" (click)="logOut()" class="btn btn-outline-secondary">Log out</button>
</div> </div>
@@ -23,7 +34,7 @@
<div class="card-body p-0"> <div class="card-body p-0">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<!-- Transaction Item --> <!-- Transaction Item -->
@for (transaction of transactions; track $index) { @for (transaction of transactions(); track $index) {
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
@@ -31,7 +42,7 @@
<i class="bi bi-person-fill fs-4 text-secondary"></i> <i class="bi bi-person-fill fs-4 text-secondary"></i>
</div> </div>
<div class="text-start"> <div class="text-start">
@if (transaction.receiverID == userID) { @if (transaction.receiverID == this.api.currentUser.id) {
<h6 class="mb-0">{{ transaction.senderID }}</h6> <h6 class="mb-0">{{ transaction.senderID }}</h6>
}@else { }@else {
<h6 class="mb-0">{{ transaction.receiverID }}</h6> <h6 class="mb-0">{{ transaction.receiverID }}</h6>
@@ -40,7 +51,7 @@
</div> </div>
</div> </div>
<div class="text-end"> <div class="text-end">
@if (transaction.receiverID == userID) { @if (transaction.receiverID == this.api.currentUser.id) {
<h6 class="mb-0 text-success"> <h6 class="mb-0 text-success">
{{ transaction.amount | currency }} {{ transaction.amount | currency }}
</h6> </h6>
@@ -53,6 +64,7 @@
</div> </div>
</div> </div>
} }
@empty {<p class="text-muted">No transactions yet</p>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,31 +1,28 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, OnInit } from '@angular/core'; import { Component, inject, OnInit, signal } from '@angular/core';
import { APIService } from '../../services/api'; import { APIService } from '../../services/api';
import Transaction from '@model/transaction'; import Transaction from '@model/transaction';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbAccordionToggle, NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from "@ng-bootstrap/ng-bootstrap";
@Component({ @Component({
selector: 'app-screen-profile', selector: 'app-screen-profile',
imports: [CurrencyPipe, DatePipe, CommonModule], imports: [CurrencyPipe, DatePipe, CommonModule, NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbDropdownItem],
templateUrl: './screen-profile.html', templateUrl: './screen-profile.html',
styleUrl: './screen-profile.less', styleUrl: './screen-profile.less',
}) })
export class ScreenProfile implements OnInit{ export class ScreenProfile implements OnInit{
username = 'John Doe'; transactions = signal<Transaction[]>([])
userID = 'testuser';
balance = 200;
transactions!: Transaction[];
constructor( constructor(
private api: APIService, protected api: APIService,
private router: Router, private router: Router,
){} ){}
ngOnInit(): void { ngOnInit(): void {
// FIXME transactions displaying delayed (only on second nav click)
this.api.getTransactions().subscribe({ this.api.getTransactions().subscribe({
next: (transactions) => { next: (transactions) => {
this.transactions = transactions; this.transactions.set(transactions);
}, },
error: (err) => { error: (err) => {
console.error('Error fetching transactions:', err); console.error('Error fetching transactions:', err);
@@ -35,7 +32,7 @@ export class ScreenProfile implements OnInit{
logOut(){ logOut(){
this.api.logout().subscribe({ this.api.logout().subscribe({
next: () => { next: () => {
this.router.navigate(['login']) this.router.navigate(['/'])
}, },
error: (err) => { error: (err) => {
console.error('Error logging out:', err) console.error('Error logging out:', err)

View File

@@ -1,28 +0,0 @@
import { Component, inject, TemplateRef } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { QrCodeComponent } from 'ng-qrcode';
@Component({
selector: 'app-screen-receive',
imports: [FormsModule, QrCodeComponent],
templateUrl: './screen-receive.html',
styleUrl: './screen-receive.less',
})
export class ScreenReceive {
private modalService = inject(NgbModal);
user = 'DemoUser';
amount: number = 0;
get shareableLink(): string {
const currentDomain = window.location.origin;
return `${currentDomain}/send/${this.user}?amount=${this.amount}`;
}
copyLink() {
navigator.clipboard.writeText(this.shareableLink);
}
open(content: TemplateRef<any>) {
this.modalService.open(content)
}
}

View File

@@ -38,19 +38,13 @@
<!-- Share Button --> <!-- Share Button -->
<ng-template #content let-modal> <ng-template #qrTemplate>
<div class="modal-header"> <qr-code [value]="shareableLink"
<h4 class="modal-title" id="modal-basic-title">Pay {{ amount}} to {{ user }}</h4> size="300"
<button type="button" class="btn-close" aria-label="Close" (click)="modal.dismiss()"></button> errorCorrectionLevel="M" />
</div>
<div class="modal-body text-center">
<qr-code [value]="shareableLink"
size="300"
errorCorrectionLevel="M" />
</div>
</ng-template> </ng-template>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button class="btn btn-primary btn-lg" (click)="open(content)"> <button class="btn btn-primary btn-lg" (click)="openModal()">
<i class="bi bi-qr-code-scan me-2"></i> Show QR Code <i class="bi bi-qr-code-scan me-2"></i> Show QR Code
</button> </button>
</div> </div>

View File

@@ -1,17 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenReceive } from './screen-receive'; import { ScreenRequest } from './screen-request';
describe('ScreenReceive', () => { describe('ScreenReceive', () => {
let component: ScreenReceive; let component: ScreenRequest;
let fixture: ComponentFixture<ScreenReceive>; let fixture: ComponentFixture<ScreenRequest>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ScreenReceive], imports: [ScreenRequest],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(ScreenReceive); fixture = TestBed.createComponent(ScreenRequest);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
}); });

View File

@@ -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;
}
}

View File

@@ -28,9 +28,10 @@
<input <input
type="text" type="text"
class="form-control" class="form-control"
placeholder="Email or phone number" placeholder="username"
[(ngModel)]="recipient" [(ngModel)]="recipient"
/> />
<button (click)="openScanner()" class="btn btn-primary"><i class="bi bi-qr-code-scan"></i></button>
</div> </div>
</div> </div>
@@ -45,7 +46,7 @@
<button class="btn btn-primary btn-lg" (click)="sendMoney()"> <button class="btn btn-primary btn-lg" (click)="sendMoney()">
Send Money Send Money
</button> </button>
<button class="btn btn-outline-secondary btn-lg" (click)="cancel()"> <button class="btn btn-outline-secondary btn-lg" (click)="clear()">
Cancel Cancel
</button> </button>
</div> </div>
@@ -55,3 +56,7 @@
</div> </div>
</div> </div>
<ng-template #qrScanner>
Abc
</ng-template>

View File

@@ -1,6 +1,10 @@
import { Component } from '@angular/core'; import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { APIService } from '../../services/api'; 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({ @Component({
selector: 'app-screen-send', selector: 'app-screen-send',
@@ -12,26 +16,44 @@ export class ScreenSend {
amount: number = 0; amount: number = 0;
recipient: string = ''; recipient: string = '';
reference: string = ''; reference: string = '';
scanner!: QrScanner;
@ViewChild('qrScanner') templScanner!: TemplateRef<any>;
constructor( constructor(
private api: APIService, private api: APIService,
private notify: NotificationService,
private modalService: NgbModal,
){} ){}
sendMoney() { sendMoney() {
this.api.send(this.amount, this.recipient, this.reference).subscribe({ this.api.send(this.amount, this.recipient, this.reference).subscribe({
next:()=> { next:()=> {
this.cancel() this.notify.success(`Sent ${this.amount} to ${this.recipient}`);
//TODO show success message this.clear()
}, },
error:()=> { error:(err)=> {
//TODO show error message 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}`);
}
} }
}); });
} }
cancel() { clear() {
this.amount = 0; this.amount = 0;
this.recipient = ''; this.recipient = '';
this.reference = ''; this.reference = '';
} }
openScanner(){
const modalRef = this.modalService.open(Modal);
modalRef.componentInstance.title = 'Scan a QR Code';
modalRef.componentInstance.body = this.templScanner;
}
closeScanner(){}
} }

View File

@@ -3,7 +3,8 @@ import { Injectable } from '@angular/core';
import { BehaviorSubject, catchError, map, Observable, of, tap } from 'rxjs'; import { BehaviorSubject, catchError, map, Observable, of, tap } from 'rxjs';
import Transaction from '@model/transaction' import Transaction from '@model/transaction'
import { SendRequest, SendResponse } from '@message/Send'; import { SendRequest, SendResponse } from '@message/Send';
import { TransactionsRequest } from '@message/Transactions'; import { LoginResponse } from '@message/Login';
import Account from '@model/user';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -13,21 +14,44 @@ export class APIService {
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false); private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
isAuthenticated$ = this.isAuthenticatedSubject.asObservable(); isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
// data holding
loggedInUser!: Account;
currentUser!: Account;
ownedAccounts!: Account[];
constructor(private http: HttpClient){} constructor(private http: HttpClient){}
login(username: string, password: string): Observable<any>{ login(username: string, password: string): Observable<any>{
return this.http.post(`${this.apiUrl}/auth/login`,{ 'username': username, 'password': password}); 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>{ logout(): Observable<any>{
return this.http.post(`${this.apiUrl}/auth/logout`, {}); 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> { checkAuthStatus(): Observable<boolean> {
return this.http.get(`${this.apiUrl}/auth/status`).pipe( return this.http.get(`${this.apiUrl}/auth/status`).pipe(
map(() => true), map(() => true),
catchError(() => of(false)), catchError(() => of(false)),
tap({ tap( authenticated => {
next: () => this.isAuthenticatedSubject.next(true), this.isAuthenticatedSubject.next(authenticated);
error: () => this.isAuthenticatedSubject.next(false),
}) })
); );
} }
@@ -35,7 +59,8 @@ export class APIService {
return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`); return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`);
} }
send(amount: number, recipientID: string, reference: string = ""): Observable<SendResponse>{ send(amount: number, recipientID: string, reference: string = ""): Observable<SendResponse>{
let request: SendRequest = {amount, recipientID, reference};
let request: SendRequest = {senderID: this.currentUser.id, amount, recipientID, reference};
return this.http.post<SendResponse>(`${this.apiUrl}/send`, request); return this.http.post<SendResponse>(`${this.apiUrl}/send`, request);
} }
} }

View File

@@ -1,14 +1,14 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { APIService } from './api'; import { APIService } from './api';
import { map } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
export const authGuard: CanActivateFn = (route, state) => { export const authGuard: CanActivateFn = (route, state) => {
const api = inject(APIService); const api = inject(APIService);
const router = inject(Router); const router = inject(Router);
//FIXME always redirected to login after page load
return api.isAuthenticated$.pipe( return api.isAuthenticated$.pipe(
take(1),
map((isAuthenticated) => { map((isAuthenticated) => {
if (isAuthenticated) { if (isAuthenticated) {
return true; return true;

View File

@@ -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();
});
});

View File

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

View File

@@ -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();
});
});

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

View File

@@ -1 +1,67 @@
/* You can add global styles to this file, and also import other style files */ :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);
}

19
docs/design/use-cases.md Normal file
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

68
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"ng-qrcode": "^21.0.0", "ng-qrcode": "^21.0.0",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -4245,6 +4246,16 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -4381,6 +4392,12 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
@@ -4823,6 +4840,29 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bcrypt/node_modules/node-addon-api": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/beasties": { "node_modules/beasties": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.1.tgz", "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.1.tgz",
@@ -8173,6 +8213,17 @@
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
} }
}, },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp-build-optional-packages": { "node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
@@ -9193,6 +9244,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qr-scanner": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
"license": "MIT",
"dependencies": {
"@types/offscreencanvas": "^2019.6.4"
}
},
"node_modules/qrcode": { "node_modules/qrcode": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -11284,6 +11344,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
@@ -11297,6 +11358,7 @@
"winston": "^3.19.0" "winston": "^3.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
@@ -11310,12 +11372,6 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
},
"shared": {
"version": "1.0.0",
"extraneous": true,
"license": "GPL-3.0",
"devDependencies": {}
} }
} }
} }

View File

@@ -9,7 +9,8 @@
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"server": "npm run dev --workspace=server", "server": "npm run dev --workspace=server",
"client": "npm start --prefix client", "client": "npm start --prefix client",
"dev": "concurrently \"npm run server\" \"npm run client\"" "dev": "concurrently \"npm run server\" \"npm run client\"",
"teardown": "npm run teardown --workspace=server"
}, },
"workspaces": [ "workspaces": [
"client", "client",

View File

@@ -15,9 +15,13 @@
"setup": "docker compose up -d", "setup": "docker compose up -d",
"teardown": "docker compose down", "teardown": "docker compose down",
"keygen": "ts-node src/scripts/keygen.ts", "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" "dev": "npm run setup && npm run node; npm run teardown"
}, },
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
@@ -31,6 +35,7 @@
"winston": "^3.19.0" "winston": "^3.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",

View File

@@ -1,9 +1,13 @@
import Account from "../model/user";
export class LoginRequest{ export class LoginRequest{
constructor( constructor(
username: string, public username: string,
password: string public password: string
){} ){}
} }
export class LoginResponse{ export class LoginResponse{
constructor(){} constructor(
public user: Account,
public ownedAccounts: Account[],
){}
} }

View File

@@ -0,0 +1,5 @@
export class GenericMessage{
constructor(
public message: string
){}
}

View File

@@ -1,5 +1,6 @@
export class SendRequest{ export class SendRequest{
constructor( constructor(
public senderID: string,
public recipientID: string, public recipientID: string,
public amount: number, public amount: number,
public reference: string public reference: string
@@ -9,6 +10,5 @@ export class SendRequest{
export class SendResponse{ export class SendResponse{
constructor( constructor(
public balance: number, public balance: number,
public message: string
){} ){}
} }

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[],
){}
}

View File

@@ -0,0 +1,11 @@
import Account from "../model/user";
export class UserRequest{
constructor(
){}
}
export class UserResponse{
constructor(
public user: Account
){}
}

View File

@@ -1,5 +1,5 @@
import { Table, Column, Model, CreatedAt, ForeignKey, BelongsTo} from 'sequelize-typescript'; import { Table, Column, Model, CreatedAt, ForeignKey, BelongsTo} from 'sequelize-typescript';
import User from './user'; import Account from './user';
@Table @Table
export default class Transaction extends Model{ export default class Transaction extends Model{
@@ -10,18 +10,18 @@ export default class Transaction extends Model{
declare reference: string; declare reference: string;
@Column @Column
@ForeignKey(()=> User) @ForeignKey(()=> Account)
declare senderID: string; declare senderID: string;
@BelongsTo(() => User, 'senderID') @BelongsTo(() => Account, 'senderID')
declare sender: User; declare sender: Account;
@Column @Column
@ForeignKey(()=> User) @ForeignKey(()=> Account)
declare receiverID: string; declare receiverID: string;
@BelongsTo(() => User, 'receiverID') @BelongsTo(() => Account, 'receiverID')
declare receiver: User; declare receiver: Account;
@CreatedAt @CreatedAt
declare date: Date; declare date: Date;

View File

@@ -1,26 +1,65 @@
import { Table, Column, Model, CreatedAt, DataType, Scopes} from 'sequelize-typescript'; import { Table, Column, Model, DataType, Scopes, DefaultScope, DeletedAt, BelongsToMany, ForeignKey} from 'sequelize-typescript';
@Table
@DefaultScope(() => ({
attributes:{ exclude: ['password']}
}))
@Scopes(() => ({ @Scopes(() => ({
withoutPassword: { withPassword: {
attributes: { exclude: ['password'] } 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 @Table
export default class User extends Model{ export class BusinessOwnership extends Model {
@ForeignKey(() => Account)
@Column
declare ownerAccountId: string;
@Column({primaryKey: true, unique: true, allowNull: false}) @ForeignKey(() => Account)
declare userID: string; @Column
declare ownedAccountId: string;
@Column @DeletedAt
declare displayName: string; declare deletedAt: Date | null;
}
@Column(DataType.DECIMAL(20,2))
declare balance: number;
@Column
declare password: string;
@CreatedAt
declare creationDate: Date;
export async function getOwnedAccounts(user: Account){
let q = await Account.findByPk(user.id, {
include: ['ownedBusinesses'],
});
let ownedAccounts = q?.ownedBusinesses;
return ownedAccounts ?? [];
} }

View File

@@ -1,35 +1,34 @@
import { compare } from 'bcrypt';
import express from 'express'; import express from 'express';
import { logger } from '../util/logging'; import { logger } from '../util/logging';
import User from '../model/user'; import Account, { getOwnedAccounts } from '../model/user';
import { Scope } from '../util/db';
import { getJWT, requireAuth } from '../util/auth'; import { getJWT, requireAuth } from '../util/auth';
import { LoginRequest } from '@message/Login'; import { LoginRequest, LoginResponse } from '../messages/Login';
import { SendRequest, SendResponse } from '@message/Send'; import { GenericMessage as Msg } from '../messages/Message';
const router = express.Router(); const router = express.Router();
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
try { try {
const { username, password } = req.body; const data : LoginRequest = req.body;
const user = await User.findOne({where: { userID: username}}); const user = await Account.scope(Scope.withPassword).findOne({where: { id: data.username}});
if (!user) return res.status(401).json({ message: 'Invalid credentials' }); if (!user) return res.status(401).json(new Msg('Invalid credentials'));
const isMatch = (password == user.password); const isMatch = await compare(data.password, user.password);
//TODO hash passwords if (!isMatch) return res.status(401).json(new Msg('Invalid credentials'));
//const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) return res.status(401).json({ message: 'Invalid credentials' });
// successfully authenticated // successfully authenticated
let jwt = await getJWT(user); let jwt = await getJWT(user);
res.cookie('jwt', jwt, { res.cookie('jwt', jwt, {
httpOnly: true, // Prevent XSS httpOnly: true, // Prevent XSS
secure: process.env.NODE_ENV === 'production',// HTTPS only secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict', // CSRF protection sameSite: 'strict', // CSRF protection
maxAge: 86400000, // 1 day maxAge: 86400000, // 1 day
}); });
res.json({ message: 'Logged in successfully' }); res.json(new LoginResponse(user, await getOwnedAccounts(user)));
}catch (err) { }catch (err) {
logger.error('Failed to authenticate:', err); logger.error('Failed to authenticate:', err);
res.status(500).json({ message: 'Failed to authenticate' }); res.status(500).json(new Msg('Failed to authenticate'));
} }
}); });

View File

@@ -1,29 +1,39 @@
import express from 'express'; import express from 'express';
import { logger } from '../util/logging'; import { logger } from '../util/logging';
import { requireAuth } from '../util/auth'; import { requireAuth } from '../util/auth';
import User from '../model/user'; import Account, { BusinessOwnership, getOwnedAccounts } from '../model/user';
import { db } from '../util/db'; import { db } from '../util/db';
import Transaction from '../model/transaction'; import Transaction from '../model/transaction';
import { SendRequest, SendResponse} from '../messages/Send'; import { SendRequest, SendResponse} from '../messages/Send';
import { GenericMessage as Err} from '../messages/Message';
const router = express.Router(); const router = express.Router();
router.post('/', requireAuth, async (req, res) => { router.post('/', requireAuth, async (req, res) => {
try { try {
const sender = res.locals.user as User; const user = res.locals.user as Account;
const data : SendRequest = req.body; const data : SendRequest = req.body;
const recipient = await User.findOne({where: {userID: data.recipientID}})
if ( Number(data.amount) <= 0) { if ( Number(data.amount) <= 0) {
// TODO return SendResponse here and everywhere else in this file return res.status(400).json(new Err('Invalid transfer amount'));
return res.status(400).json({ error: 'Invalid transfer amount' });
} }
const recipient = await Account.findOne({where: {id: data.recipientID}})
if (!recipient) { if (!recipient) {
return res.status(404).json({ error: 'Recipient not found' }); 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)){ if (Number(sender.balance) < Number(data.amount)){
logger.error(`Insufficient balance: ${sender.balance} < ${data.amount}`) logger.debug(`Insufficient balance: ${sender.balance} < ${data.amount}`)
return res.status(402).json({error: 'Insufficient balance'}) return res.status(402).json(new Err('Insufficient balance'))
} }
await db.transaction(async (t) =>{ await db.transaction(async (t) =>{
@@ -31,15 +41,15 @@ router.post('/', requireAuth, async (req, res) => {
await recipient.increment({balance: data.amount}); await recipient.increment({balance: data.amount});
await Transaction.create({ await Transaction.create({
amount: data.amount, amount: data.amount,
senderID: sender.userID, senderID: sender.id,
receiverID: recipient.userID, receiverID: recipient.id,
reference: data.reference reference: data.reference
}); });
}) })
return res.status(200).json({balance: sender.balance, amount: data.amount}); return res.status(200).json(new SendResponse(sender.balance))
} catch (err) { } catch (err) {
logger.error('Failed to commit transaction:', err); logger.error('Failed to commit transaction:', err);
return res.status(500).json({ error: 'Failed to commit transaction' }); return res.status(500).json(new Err('Failed to commit transaction' ));
} }
}); });

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),
})
})();

View File

@@ -0,0 +1,5 @@
import {hash} from 'bcrypt';
let pass = process.argv[2];
hash(pass, 10, function(err, hash) {
console.log(hash);
});

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

View File

@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import User from "../model/user" import Account from "../model/user"
import { importJWK, SignJWT, jwtVerify } from "jose"; import { importJWK, SignJWT, jwtVerify } from "jose";
@@ -9,9 +9,9 @@ async function setKeyFromEnv() {
key = await importJWK(JSON.parse(process.env.FM_PRIVATE_KEY)); key = await importJWK(JSON.parse(process.env.FM_PRIVATE_KEY));
} }
async function getJWT(user: User){ async function getJWT(user: Account){
let jwt = await new SignJWT() let jwt = await new SignJWT()
.setSubject(user.userID) .setSubject(user.id)
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setIssuedAt() .setIssuedAt()
.sign(key); .sign(key);
@@ -26,7 +26,7 @@ async function requireAuth(req: Request, res: Response, next: NextFunction) {
return res.status(401).json({ error: 'Unauthorized: No token provided' }); return res.status(401).json({ error: 'Unauthorized: No token provided' });
} }
const jwt= await jwtVerify(token, key); const jwt= await jwtVerify(token, key);
const user = await User.findOne({where: { userID: jwt.payload.sub}}); const user = await Account.findOne({where: { id: jwt.payload.sub}});
if (!user) { if (!user) {
return res.status(401).json({ error: 'Unauthorized: User not found' }); return res.status(401).json({ error: 'Unauthorized: User not found' });
} }

View File

@@ -1,8 +1,13 @@
import { Sequelize } from 'sequelize-typescript'; import { Sequelize } from 'sequelize-typescript';
import { logger } from './logging'; import { logger } from './logging';
import User from '../model/user'; import Account, { BusinessOwnership } from '../model/user';
import Transaction from '../model/transaction'; import Transaction from '../model/transaction';
enum Scope{
// for User
withPassword = 'withPassword',
}
// Initialize Sequelize // Initialize Sequelize
const db = new Sequelize({ const db = new Sequelize({
dialect: 'postgres', dialect: 'postgres',
@@ -14,7 +19,7 @@ const db = new Sequelize({
logging: logger.debug.bind(logger), logging: logger.debug.bind(logger),
}); });
db.addModels([User, Transaction ]) db.addModels([Account, BusinessOwnership, Transaction ])
// Test the connection // Test the connection
async function testConnection() { async function testConnection() {
@@ -28,4 +33,4 @@ async function testConnection() {
} }
// Export Sequelize instance and models // Export Sequelize instance and models
export { logger, db, testConnection }; export { logger, db, testConnection, Scope };