An Angular PWA From Front-End to Backend: Sign In and Add Contacts
A tutorial on how to develop sign-in and add users funcationalites to a PWA using Angular on the front-end and Spring Boot on the backend.
Join the DZone community and get the full member experience.
Join For FreeIntro
The goal of the project was to make a chat system that runs on the client, that can work offline, and has a server that stores pending messages and users. To have no barrier of entry, the client is built as a PWA so that, to use it, you just need a bookmark. The client stores the messages, contacts, and user data in the indexed DB to enable offline use. The server stores the global contacts and pending messages. The text of the messages and the passwords are encrypted on client-side and server-side.
For this project, I used Angular's PWA support on the client and Spring Boot's reactive MongoDB support on the server.
This is the first of three articles. The second will be about the online/offline login. The third will be about writing messages offline and sending and receiving messages when online.
I won't be going over the encryption of the messages in the articles because it adds optional complexity.
Sign In
To make the sign in work, the server and the client have to store user data. Then the client can restore its user data from the server and the server can provide a list of possible contacts. That requires that the client is online during the sign in.
Client-Side
The full class for the sign in can be found in the login.component.ts file shown below:
onSigninClick(): void {
let myUser = new MyUser();
myUser.username = this.signinForm.get( 'username' ).value;
myUser.password = this.signinForm.get( 'password' ).value;
myUser.email = this.signinForm.get( 'email' ).value;
/*
crypto stuff
*/
this.authenticationService.postSignin( myUser ).subscribe( us => this.signin( us ), err => console.log( err ) );
} );
}
signin( us: MyUser ): void {
this.data.myUser = null;
if ( us.username !== null ) {
this.createLocalUser( us, this.signinForm.get( 'password' ).value ).then( value => {
this.signinFailed = false;
this.dialogRef.close();
} );
} else {
this.signinFailed = true;
}
}
private createLocalUser( us: MyUser, passwd: string): PromiseLike<LocalUser> {
let localUser: LocalUser = null;
return this.cryptoService.generateKey( passwd, us.salt ? us.salt : null )
.then( ( result ) => {
localUser = {
base64Avatar: us.base64Avatar,
createdAt: us.createdAt,
email: us.email,
hash: result.a,
salt: result.b,
username: us.username,
publicKey: us.publicKey,
privateKey: us.privateKey,
userId: us.userId
};
return localUser;
} ).then( myLocalUser => this.localdbService.storeUser( myLocalUser ) )
.then( value => Promise.resolve( value ) ).then( () => localUser );
}
In lines 2-5 the MyUser Dto is filled with the values of signinForm
.
In line 9 the postSignin
of the AuthenticationService is called to send MyUser
to the server and wait for the response. If the response of the server is ok, the sign in mechanism is called.
In lines 13-15 it is checked if the username was returned by the server. That means the sign in was successful.
In lines 17-18 the flag for the successful sign in is set and the sign in dialog is closed.
In line 22 the the createUser
method is called.
In lines 26-40 the LocalUser
interface is set.
In lines 41-42 the LocalUser
interface is stored by the LocaldbService in the indexed DB of the browser and after it is stored it is return to the caller.
The full LocaldbService
class can be found in the localdb.service.ts file shown below:
@Injectable( {
providedIn: 'root'
} )
export class LocaldbService extends Dexie {
contacts: Dexie.Table<LocalContact, number>;
messages: Dexie.Table<Message, number>;
users: Dexie.Table<LocalUser, number>;
constructor() {
super( "LocaldbService" );
this.version( 1 ).stores({
contacts: '++id, name, base64Avatar, base64PublicKey, userId, ownerId',
messages: '++id, fromId, toId, timestamp, text, send, received',
users: '++id, createdAt, username, password, email, base64Avatar, userId'
});
}
/*
more methods to handle contacts and messages
*/
storeUser(user: LocalUser): Promise<number> {
return this.transaction('rw', this.users, () => this.users.add(user));
}
}
The LocaldbService
class uses the Dexie lib to create and access the browser's indexed DB. Dexie offers a good API and typing to help development. Dexie's TypeScript support enables code completion and that really helps a lot.
In Line 4 the class extends Dexie to have all the methods available in this class.
In Lines 5-7 the three tables of the application are declared.
In Lines 10-14 Dexie declares the tables. The IDs are the indexes and are created/incremented automatically.
In Lines 21-22 the LocalUser
is stored in a transaction in the indexed DB. The first parameter is 'rw' for read/write access, the second is the table that is used, and the third is the function that adds the LocalUser
to the table.
This was the local sign in. The indexed DB now has a record for the User and a local login is possible.
Server-Side
The full class for signin can be found in the AuthenticationController.java file shown below:
@PostMapping("/signin")
public Mono<MsgUser> postUserSignin(@RequestBody MsgUser myUser) {
Query query = new Query();
query.addCriteria(Criteria.where("username").is(myUser.getUsername()));
MsgUser user = this.operations.findOne(query, MsgUser.class).switchIfEmpty(Mono.just(new MsgUser())).block();
if (user.getUsername() == null) {
String encryptedPassword = this.passwordEncoder.encode(myUser.getPassword());
myUser.setPassword(encryptedPassword);
this.operations.save(myUser).block();
myUser.setUserId(myUser.get_id().toString());
return Mono.just(myUser);
}
return Mono.just(new MsgUser());
}
In lines 1-2, we provide the mapping of the REST endpoint for the method and the mapping of the request body in the MsgUser
dto.
In lines 3-4 a query for MongoDB is created to check if the username is already taken.
In line 5 the query is executed and an empty MsgUser
is returned if nothing is found.
In lines 6-8 it is checked if the username is free and the password hashed.
In lines 9-11 the new user is saved and the UserId
is set to be able to find the record in MongoDB again. After that the record is returned to the sender.
Then the record is in MongoDB to make the remote login possible.
Add Contacts
To send messages to each othe, the users have to be able to find each other. To do that, the users can search on the server for contacts and then store them in on the indexed DB in their browser.
Client-Side
The full class to add contacts can be found in the add-contacts.component.ts file:
@@Component( {
selector: 'app-add-contacts',
templateUrl: './add-contacts.component.html',
styleUrls: ['./add-contacts.component.scss']
} )
export class AddContactsComponent implements OnInit, OnDestroy {
@Output() addNewContact = new EventEmitter<Contact>();
@Input() userId: string;
@Input() myContacts: Contact[];
myControl = new FormControl();
filteredOptions: Contact[] = [];
contactsLoading = false;
myControlSub: Subscription = null;
constructor(
private contactService: ContactService,
private localdbService: LocaldbService) { }
ngOnInit() {
this.myControlSub = this.myControl.valueChanges
.pipe(
debounceTime( 400 ),
distinctUntilChanged(),
tap( () => this.contactsLoading = true ),
switchMap( name => this.contactService.findContacts( name ) ),
map(contacts => contacts.filter(con => con.userId !== this.userId)),
map(contacts => this.filterContacts(contacts)),
tap( () => this.contactsLoading = false )
).subscribe(contacts => this.filteredOptions = contacts);
}
ngOnDestroy(): void {
this.myControlSub.unsubscribe();
}
private filterContacts(contacts: Contact[]): Contact[] {
return contacts.filter(con =>
this.myContacts.filter(myCon =>
myCon.userId === con.userId).length === 0);
}
addContact() {
if(this.filteredOptions.length === 1) {
if(!this.filteredOptions[0].base64Avatar) {
this.filteredOptions[0].base64Avatar = 'assets/icons/smiley-640.jpg';
}
const localContact: LocalContact = {
base64Avatar: this.filteredOptions[0].base64Avatar,
name: this.filteredOptions[0].name,
ownerId: this.userId,
publicKey: this.filteredOptions[0].publicKey,
userId: this.filteredOptions[0].userId
};
this.localdbService.storeContact(localContact)
.then(() => {
this.addNewContact.emit(this.filteredOptions[0]);
this.myControl.reset();
this.filteredOptions = [];
});
}
}
}
In lines 1-6 the AddContactsComponent
is defined. It implements OnInit
for the setup and OnDestroy
to clean up subscriptions.
In line 8 the addContact
EventEmitter
is created to send the contact to add to the main component.
In lines 9-10 the input parameters are defined. The userId
is the id of the logged in users; the list of myContacts
is the list of already imported contacts.
In line 11 myFormControl
is created to hold the name of the Contact to add.
In lines 16-18 the LocaldbService
and ContactService
are injected in the constructor.
In lines 21 the subscripton myControlSub
is set and the valueChanges
of myFormControl
are subscribed to. The values are the names of the contacts and the subscription is needed to unsubscribe later.
In lines 23-25 the minimum time between changes is set, only changed values are sent, and the spinner flag is set to true.
In line 26 the ContactService is called to get the contacts for the name string from the server. switchMap
cancels pending server calls and sends a new one.
In line 27 the current user's contact gets filtered out of the contacts from the server.
In line 28 the existing contacts get filtered out of the contacts from the server.
In lines 29-30 the flag for the spinner is set to false and the filtered contacts are stored in fliteredOptions
.
In lines 33-35 the myControlSub
subscription is unsubscribed before the compoment is destroyed, because non-HTTP subscriptions are not unsubscribed automatically and cause memory leaks.
The template for the autocomplete can be found in add-contacts.component.html file. It is made with Angular Material and has the Add button that that calls addContact
.
In lines 44-47 it is checked that the user has selected one of the options/contacts of the filtered contacts.
In lines 48-54 the LocalContact
object is created.
In lines 55-56 the LocalContact
is stored in the indexed db of the browser.
In lines 57-59 the new contact is emitted to the main component to be shown in the contacts list and the componetent is cleared.
That is the client-side of adding a contact. Now the contact can be used to send and receive messages. The contacts are only stored in the browser so that a cleared browser db means the contacts are lost.
Server-Side
The full ContactController
class can be found in ContactController.java:
@RestController
@RequestMapping("/rest/contact")
public class ContactController {
@Autowired
private ReactiveMongoOperations operations;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@PostMapping("/findcontacts")
public Flux<Contact> getFindContacts(@RequestBody Contact contact, @RequestHeader Map<String, String> header) {
Tuple<String, String> tokenTuple = WebUtils.getTokenUserRoles(header, jwtTokenProvider);
if (tokenTuple.getB().contains(Role.USERS.name()) && !tokenTuple.getB().contains(Role.GUEST.name())) {
return operations.find(new Query()
.addCriteria(Criteria.where("username")
.regex(String.format(".*%s.*", contact.getName()))), MsgUser.class)
.take(50)
.map(myUser -> new Contact(myUser.getUsername(), myUser.getBase64Avatar(), myUser.getPublicKey(), myUser.get_id().toString()));
}
return Flux.empty();
}
In lines 1-3 the Rest Controller and the mapping is defined.
In lines 9-10 the post mapping is defined and the request body is mapped in the contact dto. The header is stored in the token tuple to have the jwt token.
In line 11-12 the Jwt token is checked if it is valid and the user has the users role.
In line 13-17 the MongoDb is queried for a username that matches the substring that was provided in the posted contact. The first 50 matches are taken and mapped in a Contact dto and returned.
Conclusion
The support of Angular makes the creation of a Pwa much easier. With the typescript support and the api of Dexie have made the use of the indexed DB become easy. Angular Material provides well documented components for the UI. With that toolbox the development of a Pwa becomes nice and typesystem guides the developer during coding. With the help of Spring Boot creating the backend becomes the smaller task. The interesting part of the development is creating the datastructures for the frontend and the backend because they have to interact with each other. The Login will cover the login dto in the indexed db and in MongoDb.
Opinions expressed by DZone contributors are their own.
Comments