Compare commits

1 Commits

Author SHA1 Message Date
eneller
d79a75d34f fix: auth guard 2026-04-11 01:41:53 +02:00
41 changed files with 124 additions and 488 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

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

@@ -40,7 +40,7 @@
<ng-template #content let-modal> <ng-template #content let-modal>
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Pay {{ amount}} to {{ this.api.currentUser.id }}</h4> <h4 class="modal-title" id="modal-basic-title">Pay {{ amount}} to {{ user }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="modal.dismiss()"></button> <button type="button" class="btn-close" aria-label="Close" (click)="modal.dismiss()"></button>
</div> </div>
<div class="modal-body text-center"> <div class="modal-body text-center">

View File

@@ -2,7 +2,6 @@ import { Component, inject, TemplateRef } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { QrCodeComponent } from 'ng-qrcode'; import { QrCodeComponent } from 'ng-qrcode';
import { APIService } from '../../services/api';
@Component({ @Component({
selector: 'app-screen-receive', selector: 'app-screen-receive',
@@ -12,12 +11,12 @@ import { APIService } from '../../services/api';
}) })
export class ScreenReceive { export class ScreenReceive {
private modalService = inject(NgbModal); private modalService = inject(NgbModal);
api = inject(APIService);
user = 'DemoUser';
amount: number = 0; amount: number = 0;
get shareableLink(): string { get shareableLink(): string {
const currentDomain = window.location.origin; const currentDomain = window.location.origin;
return `${currentDomain}/send/${this.api.currentUser.id}?amount=${this.amount}`; return `${currentDomain}/send/${this.user}?amount=${this.amount}`;
} }
copyLink() { copyLink() {

View File

@@ -28,7 +28,7 @@
<input <input
type="text" type="text"
class="form-control" class="form-control"
placeholder="username" placeholder="Email or phone number"
[(ngModel)]="recipient" [(ngModel)]="recipient"
/> />
</div> </div>
@@ -45,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>

View File

@@ -1,7 +1,6 @@
import { Component } 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';
@Component({ @Component({
selector: 'app-screen-send', selector: 'app-screen-send',
@@ -16,30 +15,21 @@ export class ScreenSend {
constructor( constructor(
private api: APIService, private api: APIService,
private notify: NotificationService,
){} ){}
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 = '';

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

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

52
package-lock.json generated
View File

@@ -4245,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",
@@ -4833,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",
@@ -8206,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",
@@ -11328,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",
@@ -11342,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",
@@ -11356,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

@@ -15,13 +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",
"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",
@@ -35,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,66 +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(() => ({
//FIXME getting account still includes password
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;
@DeletedAt @Column
declare deletedAt: Date | null; declare displayName: string;
}
@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,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,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 };