1 Commits

Author SHA1 Message Date
eneller
d79a75d34f fix: auth guard 2026-04-11 01:41:53 +02:00
54 changed files with 175 additions and 739 deletions

View File

@@ -1,17 +1,6 @@
# FakeMoney # FakeMoney
**A PayPal-like payment processor for virtual money, intended to be used for [simulation games](https://de.wikipedia.org/wiki/Schule_als_Staat).** A PayPal-like payment processor for virtual money, intended to be used for simulation games.
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,7 +23,6 @@
"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,8 +3,6 @@ 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: [
@@ -12,6 +10,5 @@ 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,5 +1,4 @@
<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 { ScreenRequest } from './screens/screen-request/screen-request'; import { ScreenReceive } from './screens/screen-receive/screen-receive';
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: ScreenRequest, component: ScreenReceive,
canActivate: [authGuard], canActivate: [authGuard],
}, },
{ {

View File

@@ -1,4 +1,4 @@
import { Component, inject, OnInit, signal } from '@angular/core'; import { Component, 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,31 +8,27 @@ 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, ToastContainer], imports: [RouterOutlet, NgbModule, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLinkBase, RouterLinkWithHref],
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(
private router: Router private router: Router,
){} ){}
ngOnInit(): void { ngOnInit(): void {
this.api.checkAuthStatus().subscribe(); // show correct active tab
this.router.events this.router.events
.pipe(filter(event => event instanceof NavigationEnd)) .pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() =>{ .subscribe(() =>{
this.active = this.router.url; this.active = this.router.url;
console.log(this.active);
}); });
} }

View File

@@ -1,7 +0,0 @@
<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

@@ -1,22 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,13 +0,0 @@
<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

@@ -1,22 +0,0 @@
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

@@ -1,13 +0,0 @@
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.set(null)" (closed)="error = null"
[dismissible]="true" [dismissible]="true"
> >
{{ error() }} {{ error }}
</ngb-alert> </ngb-alert>
}
</form> </form>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,9 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core'; import { Component } 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',
@@ -16,13 +14,12 @@ import { NotificationService } from '../../services/notification';
export class ScreenLogin { export class ScreenLogin {
loginForm: FormGroup; loginForm: FormGroup;
submitted = false; submitted = false;
loading = signal(false); loading = false;
showPassword = false; showPassword = false;
error = signal<string|null>(null); error: 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,
@@ -35,20 +32,21 @@ export class ScreenLogin {
onSubmit() { onSubmit() {
this.submitted = true; this.submitted = true;
this.error.set(null); this.error = null;
this.loading.set(true) this.loading = 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: (resp) => { error: (err) => {
let msg: GenericMessage = resp.error; //FIXME error message displaying delayed, display message from server response
this.notify.error(msg.message || 'Login failed. Please try again.'); this.error = err.error?.message || 'Login failed. Please try again.';
this.loading.set(false); this.loading = false;
} }
}); });
this.api.checkAuthStatus().subscribe();
} }
} }

View File

@@ -4,23 +4,12 @@
<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">{{ this.api.currentUser.balance | currency}}</h3> <h3 class="mb-0">{{ 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>
@@ -34,7 +23,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">
@@ -42,7 +31,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 == this.api.currentUser.id) { @if (transaction.receiverID == userID) {
<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>
@@ -51,7 +40,7 @@
</div> </div>
</div> </div>
<div class="text-end"> <div class="text-end">
@if (transaction.receiverID == this.api.currentUser.id) { @if (transaction.receiverID == userID) {
<h6 class="mb-0 text-success"> <h6 class="mb-0 text-success">
{{ transaction.amount | currency }} {{ transaction.amount | currency }}
</h6> </h6>
@@ -64,7 +53,6 @@
</div> </div>
</div> </div>
} }
@empty {<p class="text-muted">No transactions yet</p>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,28 +1,31 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, inject, OnInit, signal } from '@angular/core'; import { Component, OnInit } 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, NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbDropdownItem], imports: [CurrencyPipe, DatePipe, CommonModule],
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{
transactions = signal<Transaction[]>([]) username = 'John Doe';
userID = 'testuser';
balance = 200;
transactions!: Transaction[];
constructor( constructor(
protected api: APIService, private 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.set(transactions); this.transactions = transactions;
}, },
error: (err) => { error: (err) => {
console.error('Error fetching transactions:', err); console.error('Error fetching transactions:', err);
@@ -32,7 +35,7 @@ export class ScreenProfile implements OnInit{
logOut(){ logOut(){
this.api.logout().subscribe({ this.api.logout().subscribe({
next: () => { next: () => {
this.router.navigate(['/']) this.router.navigate(['login'])
}, },
error: (err) => { error: (err) => {
console.error('Error logging out:', err) console.error('Error logging out:', err)

View File

@@ -3,12 +3,12 @@
<div class="col-lg-6 col-md-8"> <div class="col-lg-6 col-md-8">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-success text-white"> <div class="card-header bg-success text-white">
<h3 class="mb-0">Request Money</h3> <h3 class="mb-0">Receive Money</h3>
</div> </div>
<div class="card-body p-4 text-center"> <div class="card-body p-4 text-center">
<!-- Large Amount Display --> <!-- Large Amount Display -->
<div class="mb-4"> <div class="mb-4">
<label class="form-label h5">Amount to request</label> <label class="form-label h5">Amount to Receive</label>
<div class="input-group input-group-lg mb-3"> <div class="input-group input-group-lg mb-3">
<span class="input-group-text"><i class="bi bi-wallet2"></i></span> <span class="input-group-text"><i class="bi bi-wallet2"></i></span>
<input <input
@@ -22,7 +22,7 @@
<!-- Shareable Link --> <!-- Shareable Link -->
<div class="mb-4"> <div class="mb-4">
<p class="text-muted">Share this link to request money:</p> <p class="text-muted">Share this link to receive money:</p>
<div class="input-group mb-3"> <div class="input-group mb-3">
<input <input
type="text" type="text"
@@ -38,13 +38,19 @@
<!-- Share Button --> <!-- Share Button -->
<ng-template #qrTemplate> <ng-template #content let-modal>
<qr-code [value]="shareableLink" <div class="modal-header">
size="300" <h4 class="modal-title" id="modal-basic-title">Pay {{ amount}} to {{ user }}</h4>
errorCorrectionLevel="M" /> <button type="button" class="btn-close" aria-label="Close" (click)="modal.dismiss()"></button>
</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)="openModal()"> <button class="btn btn-primary btn-lg" (click)="open(content)">
<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 { ScreenRequest } from './screen-request'; import { ScreenReceive } from './screen-receive';
describe('ScreenReceive', () => { describe('ScreenReceive', () => {
let component: ScreenRequest; let component: ScreenReceive;
let fixture: ComponentFixture<ScreenRequest>; let fixture: ComponentFixture<ScreenReceive>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ScreenRequest], imports: [ScreenReceive],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(ScreenRequest); fixture = TestBed.createComponent(ScreenReceive);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
}); });

View File

@@ -0,0 +1,28 @@
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

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

View File

@@ -1,10 +1,6 @@
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { Component } 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',
@@ -16,44 +12,26 @@ 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.notify.success(`Sent ${this.amount} to ${this.recipient}`); this.cancel()
this.clear() //TODO show success message
}, },
error:(err)=> { error:()=> {
if(err.status == 404){ //TODO show error message
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() { cancel() {
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,8 +3,7 @@ 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 { LoginResponse } from '@message/Login'; import { TransactionsRequest } from '@message/Transactions';
import Account from '@model/user';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -14,44 +13,21 @@ 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<LoginResponse>(`${this.apiUrl}/auth/login`,{ 'username': username, 'password': password}).pipe( return this.http.post(`${this.apiUrl}/auth/login`,{ 'username': username, 'password': password});
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`, {}).pipe( return this.http.post(`${this.apiUrl}/auth/logout`, {});
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( authenticated => { tap({
this.isAuthenticatedSubject.next(authenticated); next: () => this.isAuthenticatedSubject.next(true),
error: () => this.isAuthenticatedSubject.next(false),
}) })
); );
} }
@@ -59,8 +35,7 @@ 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, take } from 'rxjs/operators'; import { first, map } 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);
return api.isAuthenticated$.pipe( return api.checkAuthStatus().pipe(
take(1), first(),
map((isAuthenticated) => { map((isAuthenticated) => {
if (isAuthenticated) { if (isAuthenticated) {
return true; return true;

View File

@@ -1,17 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,16 +0,0 @@
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

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

View File

@@ -1,19 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

68
package-lock.json generated
View File

@@ -34,7 +34,6 @@
"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"
@@ -4246,16 +4245,6 @@
"@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",
@@ -4392,12 +4381,6 @@
"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",
@@ -4840,29 +4823,6 @@
"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",
@@ -8213,17 +8173,6 @@
"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",
@@ -9244,15 +9193,6 @@
"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",
@@ -11344,7 +11284,6 @@
"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",
@@ -11358,7 +11297,6 @@
"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",
@@ -11372,6 +11310,12 @@
"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,14 +15,9 @@
"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", "dev": "npm run setup && npm run node; npm run teardown"
"verify-hash": "ts-node src/scripts/verify-hash.ts",
"create-user": "ts-node src/scripts/create-user.ts",
"dev-bg": "npm run setup && npm run node",
"dev": "ts-node src/scripts/dev.ts"
}, },
"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",
@@ -36,7 +31,6 @@
"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,13 +1,9 @@
import Account from "../model/user";
export class LoginRequest{ export class LoginRequest{
constructor( constructor(
public username: string, username: string,
public password: string password: string
){} ){}
} }
export class LoginResponse{ export class LoginResponse{
constructor( constructor(){}
public user: Account,
public ownedAccounts: Account[],
){}
} }

View File

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

View File

@@ -1,6 +1,5 @@
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
@@ -10,5 +9,6 @@ export class SendRequest{
export class SendResponse{ export class SendResponse{
constructor( constructor(
public balance: number, public balance: number,
public message: string
){} ){}
} }

View File

@@ -1,15 +0,0 @@
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

@@ -1,11 +0,0 @@
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 Account from './user'; import User 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(()=> Account) @ForeignKey(()=> User)
declare senderID: string; declare senderID: string;
@BelongsTo(() => Account, 'senderID') @BelongsTo(() => User, 'senderID')
declare sender: Account; declare sender: User;
@Column @Column
@ForeignKey(()=> Account) @ForeignKey(()=> User)
declare receiverID: string; declare receiverID: string;
@BelongsTo(() => Account, 'receiverID') @BelongsTo(() => User, 'receiverID')
declare receiver: Account; declare receiver: User;
@CreatedAt @CreatedAt
declare date: Date; declare date: Date;

View File

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

View File

@@ -1,34 +1,35 @@
import { compare } from 'bcrypt';
import express from 'express'; import express from 'express';
import { logger } from '../util/logging'; import { logger } from '../util/logging';
import Account, { getOwnedAccounts } from '../model/user'; import User from '../model/user';
import { Scope } from '../util/db';
import { getJWT, requireAuth } from '../util/auth'; import { getJWT, requireAuth } from '../util/auth';
import { LoginRequest, LoginResponse } from '../messages/Login'; import { LoginRequest } from '@message/Login';
import { GenericMessage as Msg } from '../messages/Message'; import { SendRequest, SendResponse } from '@message/Send';
const router = express.Router(); const router = express.Router();
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
try { try {
const data : LoginRequest = req.body; const { username, password } = req.body;
const user = await Account.scope(Scope.withPassword).findOne({where: { id: data.username}}); const user = await User.findOne({where: { userID: username}});
if (!user) return res.status(401).json(new Msg('Invalid credentials')); if (!user) return res.status(401).json({ message: 'Invalid credentials' });
const isMatch = await compare(data.password, user.password); const isMatch = (password == user.password);
if (!isMatch) return res.status(401).json(new Msg('Invalid credentials')); //TODO hash passwords
//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(new LoginResponse(user, await getOwnedAccounts(user))); res.json({ message: 'Logged in successfully' });
}catch (err) { }catch (err) {
logger.error('Failed to authenticate:', err); logger.error('Failed to authenticate:', err);
res.status(500).json(new Msg('Failed to authenticate')); res.status(500).json({ message: 'Failed to authenticate' });
} }
}); });

View File

@@ -1,39 +1,29 @@
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 Account, { BusinessOwnership, getOwnedAccounts } from '../model/user'; import User 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 user = res.locals.user as Account; const sender = res.locals.user as User;
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) {
return res.status(400).json(new Err('Invalid transfer amount')); // TODO return SendResponse here and everywhere else in this file
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(new Err('Recipient not found' )); return res.status(404).json({ error: '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.debug(`Insufficient balance: ${sender.balance} < ${data.amount}`) logger.error(`Insufficient balance: ${sender.balance} < ${data.amount}`)
return res.status(402).json(new Err('Insufficient balance')) return res.status(402).json({error: 'Insufficient balance'})
} }
await db.transaction(async (t) =>{ await db.transaction(async (t) =>{
@@ -41,15 +31,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.id, senderID: sender.userID,
receiverID: recipient.id, receiverID: recipient.userID,
reference: data.reference reference: data.reference
}); });
}) })
return res.status(200).json(new SendResponse(sender.balance)) return res.status(200).json({balance: sender.balance, amount: data.amount});
} catch (err) { } catch (err) {
logger.error('Failed to commit transaction:', err); logger.error('Failed to commit transaction:', err);
return res.status(500).json(new Err('Failed to commit transaction' )); return res.status(500).json({ error: 'Failed to commit transaction' });
} }
}); });

View File

@@ -1,12 +0,0 @@
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

@@ -1,36 +0,0 @@
//Dev-Script
//Runs `npm run dev-bg` and listens for interrupts to execute `npm run teardown`
import { spawn } from "node:child_process";
let isShuttingDown = false;
const child = spawn("npm", ["run", "dev-bg"], {
stdio: "inherit",
shell: true,
});
async function teardown() {
console.log("Running teardown...");
const teardownProcess = spawn("npm", ["run", "teardown"], {
stdio: "inherit",
shell: true,
detached: true
});
await new Promise((resolve) => teardownProcess.on("exit", resolve));
}
async function shutdown(signal: any) {
if(isShuttingDown) return;
isShuttingDown = true;
child.kill(signal);
await teardown();
process.exit();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
child.on("exit", async (code) => {
process.exit(code ?? 0);
});

View File

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

View File

@@ -1,6 +0,0 @@
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 Account from "../model/user" import User 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: Account){ async function getJWT(user: User){
let jwt = await new SignJWT() let jwt = await new SignJWT()
.setSubject(user.id) .setSubject(user.userID)
.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 Account.findOne({where: { id: jwt.payload.sub}}); const user = await User.findOne({where: { userID: 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,13 +1,8 @@
import { Sequelize } from 'sequelize-typescript'; import { Sequelize } from 'sequelize-typescript';
import { logger } from './logging'; import { logger } from './logging';
import Account, { BusinessOwnership } from '../model/user'; import User 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',
@@ -19,7 +14,7 @@ const db = new Sequelize({
logging: logger.debug.bind(logger), logging: logger.debug.bind(logger),
}); });
db.addModels([Account, BusinessOwnership, Transaction ]) db.addModels([User, Transaction ])
// Test the connection // Test the connection
async function testConnection() { async function testConnection() {
@@ -33,4 +28,4 @@ async function testConnection() {
} }
// Export Sequelize instance and models // Export Sequelize instance and models
export { logger, db, testConnection, Scope }; export { logger, db, testConnection };