Edit subjects, work on user list, add authentication support

Improved type safety
This commit is contained in:
Norbi Peti 2022-05-07 01:56:14 +02:00
parent a0bc7d3585
commit 38b06f54e6
No known key found for this signature in database
GPG key ID: DBA4C4549A927E56
20 changed files with 135 additions and 103 deletions

View file

@ -18,6 +18,6 @@ fi
echo "Installing packages"
npm install
echo "Running application"
npm run rebuild
npm run clean
wait-for-it database:3306 -t 0
npm run start:watch

View file

@ -1,4 +1,4 @@
import { arg, authorized, GraphQLBindings, ID, mutation, query, resolver, ResolverData } from '@loopback/graphql';
import { arg, authorized, GraphQLBindings, ID, Int, mutation, query, resolver, ResolverData } from '@loopback/graphql';
import { User } from '../models';
import { repository } from '@loopback/repository';
import { RevTokenRepository, UserRepository } from '../repositories';
@ -10,9 +10,10 @@ import { SecurityBindings, UserProfile } from '@loopback/security';
import { genSalt, hash } from 'bcryptjs';
import { UserRegisterInput } from '../graphql-types/input/user-register.input';
import { validated } from '../helpers';
import { LoginResult } from '../graphql-types/user';
import { LoginResult, UserList } from '../graphql-types/user';
import { UserUpdateInput } from '../graphql-types/input/user-update.input';
import { SzakdolgozatBindings } from '../bindings';
import { listResponse } from '../graphql-types/list';
@resolver(of => User)
export class UserResolver {
@ -69,20 +70,26 @@ export class UserResolver {
@authorized()
@query(returns => User, {name: 'user'})
async findById(@arg('id') id: number): Promise<User> {
async findById(@arg('id', returns => ID) id: number): Promise<User> {
return this.userRepository.findById(id);
}
@authorized()
@query(returns => [User])
async users(@arg('limit', returns => Int) limit: number, @arg('offset', returns => Int) offset: number) {
return await listResponse(this.userRepository, offset, limit, UserList);
}
@authorized()
@mutation(returns => Boolean)
async userUpdate(@arg('id', returns => ID) id: number, @arg('user') user: UserUpdateInput): Promise<boolean> {
if (id === +this.user?.id) { //TODO: this.user
async userUpdate(@arg('user') user: UserUpdateInput): Promise<boolean> {
if (user.id === +this.user?.id) { //TODO: this.user
const loggedInUser = await this.userService.findUserById(this.user.id);
if (user.isAdmin !== undefined && loggedInUser.isAdmin !== user.isAdmin) {
throw new Error('Cannot change admin status of self');
}
}
await this.userRepository.updateById(id, user);
await this.userRepository.updateById(user.id, user);
return true;
}

View file

@ -1,10 +1,12 @@
import { User } from '../../models';
import { field, inputType } from '@loopback/graphql';
import { field, ID, inputType } from '@loopback/graphql';
import { UserProperties } from '../user';
import { property } from '@loopback/repository';
@inputType()
export class UserUpdateInput implements Partial<Pick<User, 'name' | 'email' | 'password' | 'isAdmin'>> {
export class UserUpdateInput implements Partial<Pick<User, 'id' | 'name' | 'email' | 'password' | 'isAdmin'>> {
@field(returns => ID)
id: number;
@field({nullable: true})
@property(UserProperties.email)
email?: string;

View file

@ -14,7 +14,7 @@ export interface ListResponse<T> {
list: T[];
}
export async function listResponse<T extends Entity, U extends ListResponse<T>>(repo: DefaultCrudRepository<T, number>, offset: number, limit: number, listType: ClassType<U>) {
export async function listResponse<T extends Entity, U extends ListResponse<Partial<T>>>(repo: DefaultCrudRepository<T, number>, offset: number, limit: number, listType: ClassType<U>) {
const list = new listType();
list.list = await repo.find({offset, limit});
list.count = (await repo.count()).count;

View file

@ -1,5 +1,6 @@
import { User } from '../models';
import { field, objectType } from '@loopback/graphql';
import { field, Int, objectType } from '@loopback/graphql';
import { ListResponse } from './list';
@objectType()
export class UserResult implements Pick<User, 'id' | 'email' | 'name' | 'isAdmin'> {
@ -21,6 +22,14 @@ export class LoginResult {
user: UserResult;
}
@objectType()
export class UserList implements ListResponse<UserResult> {
@field(returns => Int)
count: number;
@field(returns => [UserResult])
list: UserResult[];
}
export const UserProperties = {
id: {
type: 'number',

View file

@ -4,7 +4,6 @@ import { Observable } from 'rxjs';
import { filter, map, shareReplay } from 'rxjs/operators';
import { LoginService } from './auth/login.service';
import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Route, Router, Routes } from '@angular/router';
import { ApiService } from './services/api.service';
import { RouteData } from './app-routing.module';
import { Title } from '@angular/platform-browser';
@ -26,13 +25,11 @@ export class AppComponent implements OnInit {
{path: 'subjects', requiredRole: 'admin'}
];
routeSegments: RouteSegment[];
pageTitle: string;
private activeRouteTitle: string;
constructor(private breakpointObserver: BreakpointObserver, public loginService: LoginService, private api: ApiService,
constructor(private breakpointObserver: BreakpointObserver, public loginService: LoginService,
private router: Router, private activeRoute: ActivatedRoute, private title: Title) {
}

View file

@ -1,13 +1,51 @@
import { NgModule } from '@angular/core';
import { Injector, NgModule } from '@angular/core';
import { APOLLO_OPTIONS } from 'apollo-angular';
import { ApolloClientOptions, InMemoryCache } from '@apollo/client/core';
import { ApolloClientOptions, ApolloLink, InMemoryCache } from '@apollo/client/core';
import { HttpLink } from 'apollo-angular/http';
import { environment } from '../environments/environment';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { Router } from '@angular/router';
import { LoginService } from './auth/login.service';
const uri = environment.backendUrl + '/graphql'; // <-- add the URL of the GraphQL server here
export function createApollo(httpLink: HttpLink): ApolloClientOptions<any> {
export function createApollo(httpLink: HttpLink, routerService: Router, injector: Injector): ApolloClientOptions<any> {
const auth = setContext((operation, context) => {
const token = injector.get(LoginService).token;
if (token === null) {
return {};
} else {
return {
headers: {
Authorization: `Bearer ${token}`
}
};
}
});
const errorLink = onError(({graphQLErrors, networkError}) => {
if (graphQLErrors) {
graphQLErrors.map((graphqlError) => {
console.log(graphqlError);
if (graphqlError.message.startsWith('Error verifying token')) {
injector.get(LoginService).deleteToken();
routerService.navigateByUrl('/');
} else {
alert(graphqlError.message);
}
});
}
if (networkError) {
const errorMessage = networkError.message;
console.log(errorMessage);
alert(errorMessage);
}
});
return {
link: httpLink.create({uri}),
link: ApolloLink.from([auth, errorLink, httpLink.create({uri})]),
cache: new InMemoryCache(),
};
}
@ -17,7 +55,7 @@ export function createApollo(httpLink: HttpLink): ApolloClientOptions<any> {
{
provide: APOLLO_OPTIONS,
useFactory: createApollo,
deps: [HttpLink],
deps: [HttpLink, Router, Injector],
},
],
})

View file

@ -16,3 +16,7 @@ query Subject($id: ID!) {
description
}
}
mutation EditSubject($input: SubjectUpdateInput!) {
subjectUpdate(subject: $input)
}

View file

@ -13,3 +13,25 @@ mutation Login($email: String!, $password: String!) {
mutation Logout {
logout
}
query UserList($limit: Int!, $offset: Int!) {
users(limit: $limit, offset: $offset) {
id
name
email
isAdmin
}
}
query User($id: ID!) {
user(id: $id) {
id
isAdmin
email
name
}
}
mutation EditUser($input: UserUpdateInput!) {
userUpdate(user: $input)
}

View file

@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ApiService } from './api.service';
describe('ApiService', () => {
let service: ApiService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ApiService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -1,45 +0,0 @@
import { Injectable } from '@angular/core';
import { LoginService } from '../auth/login.service';
import { Router } from '@angular/router';
import { Apollo } from 'apollo-angular';
import { gql } from '@apollo/client';
@Injectable({
providedIn: 'root'
})
export class ApiService {
constructor(private apollo: Apollo, private loginService: LoginService, private router: Router) {
}
request(method: 'post' | 'get' | 'delete' | 'patch', url: string, body: any): Promise<any> {
const asd = this.apollo.query({
query: gql`
{
__typename
}
`
// headers: {Authorization: 'Bearer ' + this.loginService.token}
}).toPromise().then(res => res);
return asd.catch(e => {
if (e.status === 401) {
this.loginService.deleteToken();
return this.router.navigateByUrl('/auth/login');
} else {
throw e;
}
});
}
requestPage<T>(url: string, limit: number, page: number): Promise<T[]> {
const c = url.indexOf('?') === -1 ? '?' : '&';
return this.request('get', url + c + 'filter=' + encodeURI(JSON.stringify({
limit,
offset: (page - 1) * limit
})), {});
}
requestItemCount(url: string): Promise<number> {
return this.request('get', url + '/count', {}).then(count => count.count);
}
}

View file

@ -1,21 +1,25 @@
import { Component, Input, OnInit } from '@angular/core';
import { ApiService } from '../../services/api.service';
import { ActivatedRoute, Router } from '@angular/router';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Query } from 'apollo-angular';
import { Mutation, Query } from 'apollo-angular';
import { noop } from 'rxjs';
import { HasID, MutationInput, QueryResult } from '../../utility/types';
@Component({
selector: 'app-edit',
templateUrl: './edit.component.html',
styleUrls: ['./edit.component.css']
})
export class EditComponent<T extends { id: number }, U> implements OnInit {
export class EditComponent<T extends HasID, QT extends QueryResult<T>, UT extends QueryResult<T>, CT extends QueryResult<T>, MI extends Partial<T>>
implements OnInit {
item?: T;
creating = false;
isLoading = true;
@Input() gql: Query<U, {}>;
@Input() gql: Query<QT, HasID>;
@Input() updateMutation: Mutation<UT, MutationInput<MI, T>>;
@Input() createMutation: Mutation<CT, MutationInput<MI, T>>;
@Input() fields: { title: string, name: keyof T, readonly?: (item: T) => boolean }[];
@Input() itemType: T;
/**
@ -24,15 +28,20 @@ export class EditComponent<T extends { id: number }, U> implements OnInit {
@Input() beforeSubmit: (item: T) => Partial<T>;
formGroup: FormGroup;
constructor(private api: ApiService, private router: Router, private fb: FormBuilder, private route: ActivatedRoute) {
private key: string;
private id: string;
constructor(private router: Router, private fb: FormBuilder, private route: ActivatedRoute) {
}
async ngOnInit(): Promise<void> {
this.item = JSON.parse(window.localStorage.getItem(this.router.url));
window.localStorage.removeItem(this.router.url);
const url = this.route.snapshot.url;
this.id = this.route.snapshot.url[this.route.snapshot.url.length - 1] + '';
if (!this.item && url[url.length - 1].path !== 'new') {
//this.item = await this.api.request('get', this.apiPath + '/' + this.route.snapshot.url[this.route.snapshot.url.length - 1], {}); - TODO
const data = (await this.gql.fetch({id: this.id}).toPromise()).data;
this.key = Object.keys(data).filter(k => k !== '__typename')[0];
this.item = data[this.key];
}
this.formGroup = this.fb.group(this.fields.reduce((pv, cv) => {
const control = new FormControl();
@ -52,17 +61,17 @@ export class EditComponent<T extends { id: number }, U> implements OnInit {
async submit(): Promise<void> {
this.isLoading = true;
const value = Object.assign({}, this.formGroup.value, this.beforeSubmit(this.item) ?? {});
const input = Object.assign({}, this.formGroup.value, (this.beforeSubmit ?? noop)(this.item) ?? {}, {id: this.id}) as MI;
try {
if (this.item && !this.creating) {
//await this.api.request('patch', this.apiPath + '/' + this.item.id, value); - TODO
await this.updateMutation.mutate({input}).toPromise();
} else {
//await this.api.request('post', this.apiPath, value); - TODO
await this.createMutation.mutate({input}).toPromise();
}
await this.router.navigate(['..'], {relativeTo: this.route});
} catch (e) {
alert(e.message);
}
} // TODO: Clear/update cache
this.isLoading = false;
}

View file

@ -1,7 +1,6 @@
import { Component, Input, OnInit } from '@angular/core';
import { PageEvent } from '@angular/material/paginator';
import { PaginationData } from '../../utility/pagination-data';
import { ApiService } from '../../services/api.service';
import { Router } from '@angular/router';
import { Query } from 'apollo-angular';
@ -23,7 +22,7 @@ export class ListComponent<T extends { id: number }, U extends { [entityName: st
items: T[] = [];
loading = false;
constructor(private api: ApiService, private router: Router) {
constructor(private router: Router) {
}
ngOnInit(): void {

View file

@ -1,4 +1,4 @@
<app-edit [gql]="itemGQL" [itemType]="itemType" [fields]="[
{title: 'Név', name: 'name'},
{title: 'Leirás', name: 'description'}
]"></app-edit>
]" [updateMutation]="updateGQL"></app-edit>

View file

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { Subject, SubjectGQL } from '../../services/graphql';
import { EditSubjectGQL, Subject, SubjectGQL } from '../../services/graphql';
@Component({
selector: 'app-subject-edit',
@ -9,7 +9,7 @@ import { Subject, SubjectGQL } from '../../services/graphql';
export class SubjectEditComponent implements OnInit {
itemType: Subject;
constructor(public itemGQL: SubjectGQL) {
constructor(public itemGQL: SubjectGQL, public updateGQL: EditSubjectGQL) {
}
ngOnInit(): void {

View file

@ -1,4 +1,4 @@
<app-edit [itemType]="itemType" [fields]="[
<app-edit [itemType]="itemType" [gql]="itemGQL" [updateMutation]="updateGQL" [fields]="[
{title: 'E-mail', name: 'email'},
{title: 'Név', name: 'name'},
{title: 'Admin', name: 'isAdmin', readonly: isEditingSelf}

View file

@ -1,6 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { LoginService } from '../../auth/login.service';
import { UserResult } from '../../services/graphql';
import { EditUserGQL, UserGQL, UserResult } from '../../services/graphql';
@Component({
selector: 'app-user-edit',
@ -11,7 +11,8 @@ export class UserEditComponent implements OnInit {
itemType: UserResult;
isEditingSelf = user => user.id === this.userService.user.id;
constructor(private userService: LoginService) { }
constructor(private userService: LoginService, public itemGQL: UserGQL, public updateGQL: EditUserGQL) {
}
ngOnInit(): void {
}

View file

@ -1,4 +1,4 @@
<app-list apiPath="/users" [itemType]="itemType" [columns]="[
<app-list [itemType]="itemType" [gql]="listGQL" [columns]="[
{title: 'Név', prop: 'name'},
{title: 'Admin', prop: 'isAdmin'}
]"></app-list>

View file

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { UserResult } from '../../services/graphql';
import { UserListGQL, UserResult } from '../../services/graphql';
@Component({
selector: 'app-user-list',
@ -9,7 +9,7 @@ import { UserResult } from '../../services/graphql';
export class UserListComponent implements OnInit {
itemType: UserResult;
constructor() {
constructor(public listGQL: UserListGQL) {
}
ngOnInit(): void {

View file

@ -0,0 +1,5 @@
import { Scalars } from '../services/graphql';
export type HasID = { id: Scalars['ID'] };
export type QueryResult<T extends HasID> = { [entityName: string]: T };
export type MutationInput<T extends Partial<U>, U extends HasID> = { input: T };