$29.99
For this assignment, we will continue our development effort from Assignment 5.
Note: If you require a working version of assignment 5 to continue with this assignment, please email your professor.
For this assignment, we will restrict access to our app to only users who have registered. Registered users will also have the benefit of having their favourites list saved, so that they can return to it later and on a different device. To achieve this, we will primarily be working with concepts from Week 11, such as incorporating JWT in a Web API, as well as using route guards, http interceptors and localstorage in our Angular app.
Sample Solution:
https://sharp-kowalevski-020c5c.netlify.app/
Step 1: Creating a "User" API
To enable our Music App to register / authenticate users and persist their "favourites" list, we will need to create our own "User" API and publish it online (Heroku). However, before we begin writing code we must first create a "users" Database on MongoDB Atlas to persist the data. This can be accomplished by:
• Logging into your account on MongoDB Atlas: https://account.mongodb.com/account/login
• Click on the "COLLECTIONS" button in the left pane of the "Sandbox" next to the "…" button
• Once MongoDB Atlas is finished "Retrieving list of databases and collections...", you should see a list of your databases with a "+ Create Database" button.
• Choose whatever "DATABASE NAME" you like, and add "users" as your "COLLECTION NAME"
• Once this is complete, click the "Clusters" button under "DATA STORAGE" to return to the "Sandbox"
• From here, you can click the "CONNECT" button, followed by "Connect your application"
• Copy the "connection string" – it should look something like: mongodb+srv://YourMongoDBUser:<password>@clusterInfo.abc123.mongodb.net/myFirstDatabase?retryWrit es=true&w=majority
• Add your Database User password in place of <password> and your "DATABASE NAME" (from above) in place of myFirstDatabase
• Save your updated "connection string" value (we'll need it when we create our User API)
Now that we have a database created on MongoDB Atlas, we can proceed to create our User API using Node / Express. To begin, you can use the following code as a starting point:
You will notice that the starter code contains everything that we will need to start building our API. The only task left is for us to create / secure the routes and publish the server online (Heroku). You will notice however, that there is an extra file included in the sample solution: ".env". This file is used by dotenv to store environment variables that can be accessed from our code locally with process.env. Once the code is online, these values will also need to be created as environment ("config") variables within that environment – see: https://devcenter.heroku.com/articles/config-vars.
At the moment, this file contains two values: MONGO_URL and JWT_SECRET. MONGO_URL is used by the "user-service" module and JWT_SECRET will be used by your code to sign a JWT payload as well as to validate an incoming JWT.
With our environment variables in place, we can now concentrating on creating and securing our routes. This will involve correctly setting up "passport" to use a "JwtStrategy" (passportJWT.Strategy) and initializing the passport middleware for use in our server. Everything required to accomplish this task is outlined in the Week 11 Notes under the heading "Adding the code to server.js". The primary differences are:
• We will be using the value of "secretOrKey" from process.env.JWT_SECRET (from our .env file) instead of hardcoding it in our server.js
• The Strategy will not be making use of "fullName" (jwt_payload.fullName) or "role" (jwt_payload.role), since our User data does not contain these properties
The following is a list of route specifications required for our User API (HINT most of the code described below is very similar to the code outlined in the Week 11 notes, so make sure you have them close by for reference):
POST /api/user/register
This route invokes the ".registerUser()" method of "userService" and provides the request body as the single parameter.
• If the promise resolves successfully, send the returned (success) message back to the client as a json formatted object containing a single "message" property (with the value of the returned message).
• If the promise is rejected, send the returned (error) message back to the client using the same strategy (ie: a JSON formatted object with a single "message" property). However, the status code must also be set to 422.
POST /api/user/login
This is the route responsible for validating the user in the body of the request, as well as generating a "token" to be sent in the response. This is accomplished by invoking the ".checkUser()" method of "userService" and providing the request body as the single parameter.
• If the promise resolves successfully, use the returned "user" object to generate a "payload" object consisting of two properties: _id and userName that match the value returned in the "user" object. This will be the content of the JWT sent back to the client.
Sign the payload using the "jwt" module (required at the top of server.js as "jsonwebtoken") and the secret from process.env.JWT_SECRET (from our .env file).
Once you have your signed token, send a JSON formatted object back to the client with a "message" property that reads "login successful" and a "token" property that has the signed token.
• If the promise is rejected, send the returned (error) message back to the client using the same strategy (ie: a JSON formatted object with a single "message" property). However, the status code must also be set to 422.
GET /api/user/favourites – (protected using the passport.authenticate() middleware)
Here, we simply have to obtain the list of favourites for the user (only if the user provided a valid JWT). This can be accomplished by invoking the ".getFavourites()" method of "userService" and providing req.user._id as the single parameter. This way, we will provide the correct favourites list for the correct user, based on the _id value sent to the server in the JWT.
• If the promise resolves successfully, send the JSON formatted data back to the client
• If the promises is rejected, send a message back to the client as a json formatted object containing a single "error" property (with the value of the returned error).
PUT /api/user/favourites/:id – (protected using the passport.authenticate() middleware)
This route is responsible for adding a specific favourite (sent as the "id" route parameter) to the user's list of favourites (only if the user provided a valid JWT). This is accomplished by invoking the
".addFavourite()" method of "userService" and providing req.user._id as the first parameter and the id route parameter as the second parameter.
• If the promise resolves successfully, send the JSON formatted data back to the client
• If the promises is rejected, send a message back to the client as a json formatted object containing a single "error" property (with the value of the returned error).
DELETE /api/user/favourites/:id – (protected using the passport.authenticate() middleware)
Finally, this route is responsible for removing a specific favourite (sent as the "id" route parameter) from the user's list of favourites (only if the user provided a valid JWT). This is accomplished by invoking the ".removeFavourite()" method of "userService" and providing req.user._id as the first parameter and the id route parameter as the second parameter.
• If the promise resolves successfully, send the JSON formatted data back to the client
• If the promises is rejected, send a message back to the client as a json formatted object containing a single "error" property (with the value of the returned error).
With these routes in place, your User API should now be complete. The final step is to push it to Heroku (recall: Getting Started With Heroku from WEB322 and our Assignment 1 in this course).
However, there is one small addition that we need to ensure is in place for our User API to work once it's on Heroku – Setting up the MONGO_URL and JWT_SECRET Config Variables:
• Login to Heroku to see your dashboard
• Click on your newly created application
• Click on the "Settings" tab at the top (next to "Access")
• Click the "Reveal Config Vars" button
• Enter JWT_SECRET in the "KEY" textbox and add your JWT_SECRET (from .env) in the "VALUE" textbox (without quotes) and hit the "Add" button
• Similarly, enter MONGO_URL in the "KEY" textbox and add your MONGO_URL (from .env) in the "VALUE" textbox (without quotes) and hit the "Add" button
This will ensure that when we refer to either MONGO_URL or JWT_SECRET in our code using process.env, we will end up with the correct value.
This completes the first part of the assignment (setting up your User API). Please record the URI, ie: "https://some-randomName-123.herokuapp.com/api/user" somewhere handy, as this will be the "userAPIBase" used in our Angular application.
Step 2: Updating our Angular App
Now that we have our User API in place, we can make some key changes in our Angular App to ensure that only registered / logged in users can view the data, as well as to finally persist their favourites list in our mongoDB "users" collection. (HINT: Once again, most of the code described below is very similar to the code outlined in the Week 11 notes, so make sure you have them close by for reference).
Updating environment.ts / environment.prod.ts
Since we just completed setting up our User API on Heroku, why don't we start by adding it to our environment.ts and environment.prod.ts files as: userAPIBase, ie:
export const environment = {
…
userAPIBase: Users API on Heroku, ie: "https://some-randomName-123.herokuapp.com/api/user"
};
Installing New Packages / Types
Before we start creating our services and new components, we must first use npm to install a couple of packages and make one small configuration change to tsconfig.app.json:
• Use npm to install @auth0/angular-jwt
• Use npm to install @types/spotify-api with the save-dev option, ie: npm i @types/spotify-api --save-dev
• Open the "tsconfig.app.json" file and remove the "types" property from the "compilerOptions" property
While we're at it, let's also create two of our own types in their own files, ie:
• /src/app/User.ts
export class User{
"_id": string;
"userName": string;
"password": string;
}
• /src/app/RegisterUser.ts
export class RegisterUser{
"userName": string;
"password": string;
"password2": string;
}
Creating an "AuthService"
Since we will be requiring users to be authenticated to view / interact with our data, our next step should be to create an "AuthService"
Once you have created your "AuthService" (using the command ng g s Auth), you can use the following as a starting point to build out your service:
You will notice that a number of imports are in place for you, including "environment". This will allow you to reference your "userAPIBase" environment value using the code: environment.userAPIBase
Finally, we must complete the service by implementing the following functions:
• getToken() – return type string
This method simply pulls the item "access_token" from "localStorage" and returns it.
• readToken() – return type User
This method also pulls the item "access_token" from "localStorage", however it uses "helper" from "JwtHelperService()" to decode it and return the decoded value.
• isAuthenticated() – return type Boolean
Once again, this method pulls "access_token" from "localstorage". If the token was present in localStorage, return true, otherwise return false
• login(user) – return type Observable<any>
This method will take the user parameter (type: User) and attempt to "log in" using the User API. This is done by sending the user data via a POST request to environment.userAPIBase/login using the HttpClient service (http).
The return value for this method is the return value from the http.post method call (ie: the Observable)
• logout()
All this method does is remove "access_token" from "localStorage"
• register(registerUser) return type Observable<any>
This method will take the registerUser parameter (type: registerUser) and attempt to "register" using the User API. This is done by sending the registerUser data via a POST request to environment.userAPIBase/register using the HttpClient service (http).
The return value for this method is the return value from the http.post method call (ie: the Observable)
Creating a "RegisterComponent" and testing the AuthService
With our AuthService in place, it makes sense to move on to creating a "RegisterComponent" and see if we can use our App to register users in the system.
To begin, create a new RegisterComponent using the "ng" command
• mat-card { max-width: 400px; margin: 2em auto;
text-align: center;
}
mat-form-field {
display: block;
}
.success{ text-align: center;
padding-top:10px;
}
(Again, this is only a suggestion, as you're free to style the app however you wish.)
With the boilerplate code in place, we can now start on writing the logic for the component class template according to the following specification:
• The component must have the following properties:
o success (default value false) o loading (default value: false)
• The component requires an instance of the "AuthService"
• There is only one method implemented: onSubmit(), which must be invoked when the form in registration.component.html is submitted. This function must:
o First, ensure that registerUser.userName is not blank ("") and that registerUser.password equals registerUser.password2
o If this is the case, set loading to true and invoke the register method on the instance of "AuthService" with the registerUser object (defined as a property in the class) as its single parameter. Be sure to subscribe to the returned Observable.
o If the Observable broadcasts that it was successful, then we must set:
▪ success to true
▪ loading to false
o If the Observable broadcasts that there was an error, then we must set
▪ success to false
• The rest of the logic is handled in the template itself. By setting different states in the component, ie:
o Ensure that the form successfully handles the submit event and all of the form fields are bound correctly to their corresponding properties in the "registerUser" object
o Show a different submit button message if loading is true, ie: "Processing Request" (NOTE: This will help keep the user informed if our User API is currently "asleep" when the user tries to register) o Show the <div> with class "success" only if success is true o Ensure that the "Login" button links to the "/login" route
• Finally, add the "register" path to the Routes array in your app-routing.module.ts file, so that we can test the component.
• Navigate to /register and try testing all the scenarios (ie: passwords don't match, user already taken, etc). You can confirm whether or not the user was added by logging into mongoDB Atlas and looking at the data in the "users" collection (created at the beginning of this assignment)
Creating a "LoginComponent" and testing the AuthService
Now that we know that we can register users in the system, let's allow them to actually log in. Start by creating a new LoginComponent using the "ng" command
With the boilerplate code in place, we can now start on writing the logic for the component class template according to the following specification:
• The component must have the following properties:
o loading (default value: false)
• The component requires an instance of the "AuthService" and "Router" (from @angular/router)
• There is only one method implemented: onSubmit(), which must be invoked when the form in login.component.html is submitted. This function must:
o First, ensure that user.userName and user.password are not blank ("")
o If this is the case, set loading to true and invoke the login method on the instance of
"AuthService" with the user object (defined as a property in the class) as its single parameter. Be sure to subscribe to the returned Observable.
o If the Observable broadcasts that it was successful, then we must set:
▪ loading to false
▪ The "access_token" property in "localStorage" to the .token property of the success message broadcast from the observable, ie: success.token
▪ Finally, use the "router" service ("Router") to navigate to the "/newReleases" route
o If the Observable broadcasts that there was an error, then we must set
• The rest of the logic is handled in the template itself. By setting different states in the component, ie:
o Ensure that the form successfully handles the submit event and all of the form fields are bound correctly to their corresponding properties in the "user" object
o Show a different submit button message if loading is true, ie: "Processing Request" (NOTE: This will help keep the user informed if our User API is currently "asleep" when the user tries to log in) o Ensure that the "Register" button links to the "/register" route
• Finally, add the "login" path to the Routes array in your app-routing.module.ts file, so that we can test the component.
Navigate to /login and try testing all the scenarios (ie: user name can't be found, incorrect password, etc). You can confirm whether or not the access_token was added to "localStorage" by using the developer tools in the browser. You can decode the token using a service like https://jwt.io.
Creating an "InterceptTokenService" HttpInterceptor
Now that our token is correctly added to Local Storage, let's write an HttpInterceptor service to automatically add that token to every request except if the request url contains "spotify.com" – we already add the correct token in this case.
To begin, create a new "InterceptTokenService" (ng g s InterceptToken). From here, you can follow the guide on the Week 11 Notes, as the service that we will be creating is almost identical.
However, instead of always cloning the request and adding the new "Authorization" header, we will only clone the request if request.url does not include "spotify.com", ie:
• if (!request.url.includes("spotify.com")) {
// clone the request and use the "setHeaders" property to set an "Authorization" header, etc.
}
This will ensure that we only set the "JWT" authorization for requests going to our User API and all requests going to spotify.com will continue to use the "Bearer" authorization
Finally, we must "register" our Interceptor by adding the "InterceptTokenService" class to the "providers" Array in app.module.ts (See the Week 11 Notes for instructions).
Updating MusicDataService "favourites" methods
Since our favourites are now handled by our User API on Heroku, there's some work that needs to be done to refactor the 3 methods that work with favourites in our MusicDataService.
• To begin, you can delete the "favouritesList" property declared in the service (we will no longer be using it)
• Next, add the import statement:
import { environment } from './../environments/environment';
• Finally, you can use the following code as boilerplate for the three methods, ie: "addToFavourites", "removeFromFavourites" and "getFavourites". You will need to follow the "TODO" comments to complete each function.
Additionally, we must update our AlbumComponent (album.component.ts) to use the new version of "addToFavourites", ie:
• Instead of checking the return value of the .addToFavourites(id) method from our MusicDataService, we must subscribe to it in order to see if it was successful or not:
o If the Observable broadcasts that it was successful, then we must:
▪ Open the "snackbar" as before, indicating that adding the song to favourites is "Done"
o If the Observable broadcasts that there was an error, then we must set
▪ Open the "snackbar" as before, only indicate that an error has occurred, ie: "Unable to add song to Favourites"
Finally, once the methods and updates are complete, you will find that if you test your application, any favourites added will remain on the page after a refresh or leaving the site (provided that you didn't manually delete the "access_token" in Local Storage using the developer tools in the browser)
Updating AppComponent to use the Token in Local Storage
Since we have access to the token (via our AuthService), we can leverage this to update the nav / sidebar to reflect whether or not the user has been authenticated (logged in). This will involve updating both the component itself (app.component.ts) and the template (app.component.html):
app.component.ts
• Modify the class definition for AppComponent such that it "implements OnInit"
• add a "token" property
• Inject both the Router (from @angular/router) and AuthService (from ./auth.service) in the constructor
• When the component is initialized (ie: ngOnInit), subscribe to router.events (from @angular/router) and when navigation starts (ie: event instanceof NavigationStart) use the AuthService to read the token from localStorage (using .readToken()) and assign the token to our newly created "token" property.
NOTE: This process is identical to the "Update the ngOnInit() Method" step towards the end of the Week
11 Notes
• Add a logout() method that:
o clears out local storage, ie: localStorage.clear(); o uses the injected Router instance to navigate to "/login"
app.component.html
• only render the "menu" button:
o <button mat-icon-button (click)="snav.toggle()"> … </button>
If the "token" property exists
• only render the "user name" button:
o <button mat-button [matMenuTriggerFor]="userMenu"> … </button>
if the "token" property exists
• Change your hard-coded user name to instead read the .userName property of the "token".
• When the "Log out" menu item, ie:
o <a mat-menu-item>
<mat-icon>exit_to_app</mat-icon>
<span>Log out</span> </a>
is clicked, execute the "logout()" method
• Finally, update the [opened] property binding of the Sidenav element:
o <mat-sidenav #snav … > … </mat-sidenav>
Such that instead of immediately setting it to false, ie: [opened]="false", instead set it to:
[opened] = "token && snav.opened". This will ensure that the sidebar closes once the user logs out.
With these changes in place, you should now be able to register, log in and navigate around your app as before, only now you should see your user name in the nav bar. When you click the "log out" dropdown item, the sidebar and user name disappears and you're redirected back to "/login".
The app should now closely resemble the sample, however there is still one piece of functionality missing – users can still access /newReleases, /album/:id , /artist/:id, etc. even though they aren't logged in by simply typing in the route in the navbar. To solve this problem, we will have to add a simple "route guard"
Protecting Routes with a "Route Guard"
To solve the problem identified above, we need to ensure that only authenticated users can access certain routes. For this final service, we will literally be recreating the GuardAuthService from the Week 11 Notes.
• Begin by creating a new "GuardAuthService" using the ng command (ng g s GuardAuth)
• Follow along with the course notes and add the same class definition / import statements
• Once complete, update the following routes in app-routing.module.ts to use the canActivate property with the value [GuardAuthService]:
o path: 'newReleases' o path: 'search' o path: 'favourites' o path: 'artist/:id' o path: 'album/:id' o path: 'about'
Once this is complete, save the changes and test the app. You should find that if you try to access any of the above routes, you're redirected back to "/login".
Final Development Note: Spotify Types
When using a complicated API like the one offered at Spotify, it can be difficult to figure out how the data is represented. There are many properties with nested arrays, which contain more properties and nested arrays, etc, etc. If we ever wish to use our MusicDataService in the future, it would be nice to know exactly which properties exist in the returned objects, instead of having to inspect the data and go from there.
Fortunately, since we have been using TypeScript, we can actually assign a type to the data returned from our Observables and let the IntelliSense feature of VS Code suggest properties - we have seen this in our code examples in class and online. The challenge is figuring out what the data looks like and writing types for it.
This is a common problem and lucky for us, there's a solution – install the types! The kind people at "DefinitelyTyped" (https://definitelytyped.org/) have done the work for us and made the types available on NPM. We installed the package as a "dev dependency" at the start of the assignment.
As a final task, update all of the methods of the MusicDataService that request data from the Spotify API to use one of the Spotify data types listed below – ie: instead of returning Observable<any> (or simply just Observable), return Observable<someSpotifyType>
The types that you will need are:
• SpotifyApi.ArtistsAlbumsResponse
• SpotifyApi.ArtistSearchResponse
• SpotifyApi.ListOfNewReleasesResponse
• SpotifyApi.MultipleTracksResponse
• SpotifyApi.SingleAlbumResponse
• SpotifyApi.SingleArtistResponse
Step 3 Publishing the App
As a final step, we must publish the application online. This can be any of the methods discussed in class, ie: Vercel / Netlify.
However, please note that you must use a private Github Repository when publishing your code:
Assignment Submission:
• Add the following declaration at the top of your app.component.ts file:
/********************************************************************************* * WEB422 – Assignment 06
* assignment has been copied manually or electronically from any other source (including web sites) or * distributed to other students.
*
*
* Online Link: _______________________________________________________________
*
********************************************************************************/
• Compress (.zip) the all files in your Visual Studio code folder EXCEPT the node_modules folder (this will just make your submission unnecessarily large, and all your module dependencies should be in your package.json file anyway).
Important Note:
• Submitted assignments must run locally, ie: start up errors causing the assignment/app to fail on startup will result in a grade of zero (0) for the assignment.