If you've got an Angular app and you need it to support image upload with a preview, you'll be happy to learn that the solution is easier than you think.

However, you'll need to make a change in two different applications: on the Spring Boot side (where you're handling server-side activity) and on the Angular side (where you're handling client-side activity).

On the server side, you'll handle the file upload itself. The Spring Boot application will also place the image on the file system on the server.

On the client side, you'll handle the user interface that enables the user to upload an image and see its preview.

As is always the case, you can jump right to the code (for Angular or Spring Boot). Or you hang around here and learn the method behind the madness.

Remember: if you're having problems running the Angular app locally, be sure to check the README.

Please note: this guide is a part of an ongoing series of tutorials on how to create a customer relationship management (CRM) application from scratch. The GitHub code for each guide belongs to a specific branch. The master branch holds the entire application up to the current date. If you want to follow the whole series, just view the careydevelopmentcrm tag. Note that the website displays the guides in reverse chronological order so if you want to start from the beginning, go to the last page.

The Business Requirements

Your boss Smithers walks into your office to tell you that he's getting heat from the C-suite that the CRM application you're working on needs to support profile image uploads.

"People need to upload their pictures so they can look at themselves when using the app," he says.

You're a bit confused by the rationale, but you understand that people generally like to set profile pics on social media apps and even on tools they use at work.

"Please put something together so people can upload images!" Smithers says with a little tiny bit of spit flying off his tongue.

He leaves your office.

New Security Detail

The first thing you need to do is update the WebSecurityConfig class. in your User Service application that we haven't looked at in a long time. Specifically, you'll need to add support for cross-origin requests.

Now, if you're asking yourself: "What the heck does a cross-origin request have to do with uploading a file?" then be advised you're probably not the only one reading this who's asked that question.

Before answering it, take a look at the change.

	@Override
	protected void configure(HttpSecurity httpSecurity) throws Exception {
		httpSecurity
		    .cors().and()
		    .csrf().disable()
		    .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
		    .authorizeRequests() 
		    .antMatchers("/authenticate").permitAll()
		    .antMatchers(HttpMethod.GET, "/contact/**").access("hasAuthority('JWT_USER')")
		    .anyRequest().authenticated().and()
		    .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and()
		    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
	}

The only difference between that block of code and what you saw in the guide where I first covered this is this line:

cors().and()

What does that do? It sets up Spring Boot to allow cross-origin requests.

However, at this point in time, there's no additional configuration as to what to accept. In other words, this code takes a short-cut just to get things working for testing purposes.

Please be advised that you should not do this in production. Instead, consult a digital security expert so you can harden your application against threats.

By the way, if you're unfamiliar with cross-origin requests, they live up to their name. A cross-origin request is a request from a separate domain, port, or protocol. 

For security purposes, they're typically blocked when encountered from client-side scripts.

Now, on to the question of the hour: why is this cross-origin request setting necessary for file uploads?

In a nutshell, it's because the file upload solution that you'll use requires something called a pre-flight request. In this case, it's a check before the actual file upload to ensure that the user is allowed to send the request.

If you don't enable cross-origin requests, the user won't be able to upload the file.

So why wasn't this cross-origin request thing necessary for user login? Because some requests don't require a preflight request while others do.

It's a bit out of scope for this article to dig into the weeds about that, but you can learn more here

Utility Belts

Next, it's time to add a couple of utility classes. They'll offer convenience methods that you can call on when users upload files.

First up is FileNameUtil.

public class FileNameUtil {
	
	public static String createFileName(User user) {
		String fileName = null;
		
		if (user == null) throw new IllegalArgumentException("User cannot be null!");
		if (StringUtils.isEmpty(user.getId())) throw new IllegalArgumentException("User ID cannot be null!");
		
		Long currentTime = System.currentTimeMillis();
		
		StringBuilder builder = new StringBuilder(user.getId());
		builder.append("-");
		builder.append(currentTime.toString());
		
		fileName = builder.toString();
		
		return fileName;
	}
	
	
	public static String createFileName(User user, String originalFileName) {
		String fileName = createFileName(user);

		fileName = appendExtensionFromOriginalFileName(fileName, originalFileName);
		
		return fileName;
	}
	
	
	public static String appendExtensionFromOriginalFileName(String fileName, String originalFileName) {
		if (StringUtils.isEmpty(fileName)) throw new IllegalArgumentException("File name can't be null!");
		if (StringUtils.isEmpty(originalFileName)) throw new IllegalArgumentException("Original file name can't be null!");
		
		StringBuilder builder = new StringBuilder(fileName);
		if (!fileName.endsWith(".")) builder.append(".");
		
		String currentExtension = getCurrentExtensionFromFileName(originalFileName);
		builder.append(currentExtension);
		
		String newFileName = builder.toString();
		
		return newFileName;
	}
	
	
	public static String getCurrentExtensionFromFileName(String fileName) {
		if (StringUtils.isEmpty(fileName)) throw new IllegalArgumentException("File name can't be null!");
		if (fileName.indexOf(".") == -1) throw new IllegalArgumentException("File name doesn't have an extension!");
		
		int lastPeriodLoc = fileName.lastIndexOf(".");
		String extension = fileName.substring(lastPeriodLoc + 1, fileName.length());
		
		return extension;
	}
	
	
	public static boolean fileNameHasSpecialChars(String fileName) {
		if (StringUtils.isEmpty(fileName)) throw new IllegalArgumentException("File name can't be null!");
		
		return false;
	}
}

Most of those method names explain what they do.

Bottom line here: when a user uploads a file, the application renames it. The new name is a concatenation of the user ID and the current time out to milliseconds. That name guarantees uniqueness unless the same user uploads two files at exactly the same millisecond.

Not likely.

Next up is FileUtil.

@Component
public class FileUtil {

    private static final Logger LOG = LoggerFactory.getLogger(FileUtil.class);
	
    private static final String PROFILE_DIR = "profile";
    
    @Value("${user.files.base.path}")
    private String userFilesBasePath;

    @Value("${max.file.upload.size}")
    private Long maxFileUploadSize;
    
	
    public void copyFile(MultipartFile file, User user) throws MissingFileException, FileTooLargeException, CopyFileException {
        validateFile(file, maxFileUploadSize);
        saveFile(file, user);
    }

    
    private void saveFile(MultipartFile file, User user) throws CopyFileException {
        try (InputStream is = file.getInputStream()) {
            String newFileName = getNewFileName(file, user);
            Path rootLocation = Paths.get(getRootLocationForUserProfileImageUpload(user));
            Files.copy(is, rootLocation.resolve(newFileName));
        } catch (IOException ie) {
            LOG.error("Problem uploading file!", ie);
            throw new CopyFileException("Failed to upload!");
        }
    }
    
    
    private void validateFile(MultipartFile file, Long maxFileUploadSize) throws MissingFileException, FileTooLargeException {
        checkFileExistence(file);
        checkFileSize(file, maxFileUploadSize);
    }
    
    
    private String getNewFileName(MultipartFile file, User user) {
        LOG.debug("File name is " + file.getOriginalFilename());
                
        String newFileName = FileNameUtil.createFileName(user, file.getOriginalFilename());
        LOG.debug("New file name is " + newFileName);
        
        return newFileName;
    }
    
	   
	public void checkFileSize(MultipartFile file, Long maxFileUploadSize) throws FileTooLargeException {
	    if (file.getSize() > maxFileUploadSize) {
	        String message = "File is too large - max size is " + maxFileUploadSize;
	        throw new FileTooLargeException(message);
	    }
	}

	
	public void checkFileExistence(MultipartFile file) throws MissingFileException {
	    if (file == null) throw new MissingFileException("No file sent!");
	    if (StringUtils.isEmpty(file.getName())) throw new MissingFileException("No file sent!");
	}
	
	
	private void createDirectoryIfItDoesntExist(String dir) {
		final Path path = Paths.get(dir);
		
		if (Files.notExists(path)) {
			try {
				Files.createDirectories(path);
			} catch (IOException ie) {
				LOG.error("Problem creating directory " + dir);
			}
		} 
	}
	
	
	public String properSeparators(String filePath) {
		if (filePath != null) {
			String properPath = filePath.replaceAll("\\\\", "/");
			return properPath;
		} else {
			return null;
		}
	}
		    
	    
    public String getRootLocationForUserUpload(User user) {
        if (user == null) throw new IllegalArgumentException("No user provided!");
        if (StringUtils.isEmpty(user.getId())) throw new IllegalArgumentException("No user id!");
        
        StringBuilder builder = new StringBuilder();
        
        builder.append(userFilesBasePath);
        builder.append("/");
        builder.append(user.getId());
        
        String location = builder.toString();
        
        createDirectoryIfItDoesntExist(location);
        
        return location;
    }
    

    public String getRootLocationForUserProfileImageUpload(User user) {
        if (user == null) throw new IllegalArgumentException("No user provided!");
        if (StringUtils.isEmpty(user.getId())) throw new IllegalArgumentException("No user id!");

        String base = getRootLocationForUserUpload(user);
        
        StringBuilder builder = new StringBuilder(base);
        builder.append("/");
        builder.append(PROFILE_DIR);
        
        String location = builder.toString();
        
        createDirectoryIfItDoesntExist(location);
        
        return location;
    }   
}

That's the class that you'll use to copy a MultipartFile object to your server.

Pay particular attention to the copyFile() method. That's where the copy process originates.

That method first validates the file to ensure it follows the rules (not too large, must actually exist, etc.). If it passes the rules, then it delegates the actual copy process to the saveFile() method.

That method writes the file to the server.

Now, keep in mind that you're going to need some settings in application.properties for this thing to work. Here they are:

user.files.base.path=/etc/careydevelopment/users
max.file.upload.size=524288

The second property is easier to cover so I'll explain that first. It dictates the maximum file size. In this case, it's 500k.

The first property is a path to the root directory where files will be stored.

Keep in mind, though, that user uploads get stored in a subdirectory under the base path that's named according to the user's ID. So if the user's ID is 36, then that user's base path is /etc/careydevelopment/users/36.

It gets more interesting. The profile photo is stored in the profile subdirectory under that path. So the real path for profile uploads for user ID 36 is /etc/careydevelopment/users/36/profile.

Where does the /profile extension come from? Check out this static field in FileNameUtil:

    private static final String PROFILE_DIR = "profile";

That's where it comes from.

By the way, all this means that you need to have your ducks in a row when it comes to deployment. Create the base path (/etc/careydevelopment/users). Then, make sure the User Service application has write access to it.

The FileNameUtil class also relies on a few exceptions that you need to create. They're really simple and you can find the source code on GitHub.

Controller Freak

Next, create a controller that will handle file uploads. Call it FileUploadController.

@CrossOrigin(origins = "*")
@RestController
public class FileUploaderController {
	
	private static final Logger LOG = LoggerFactory.getLogger(FileUploaderController.class);
	
	@Autowired
	private FileUtil fileUtil;

	
	@PostMapping("/user/profileImage")
	public ResponseEntity<?> saveProfileImage(@RequestParam("file") MultipartFile file) {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		User user = (User)authentication.getPrincipal();
		LOG.debug("User uploading is " + user);
		
		try {
			fileUtil.copyFile(file, user);
			
            return ResponseEntity.ok().build();
		} catch (FileTooLargeException fe) {
			return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
		} catch (MissingFileException me) {
	        return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
		} catch (Exception e) {
			return ResponseEntity.status(HttpStatus.EXPECTATION_FAILED).build();
		}
	}
}

If you look at the very top of that class, you'll notice the @CrossOrigin annotation. You'll also notice that it's configured to accept requests from all destinations.

Once again: don't do this in production. This is only for testing.

Next, you'll see a @RestController annotation. That's expected because this is an API that accepts REST requests.

The class also includes an autowired FileUtil instance. That's the class you saw in the previous section.

There's only a single method in FileUploadController. Unsurprisingly, it handles file uploads.

It's a method that handles POST requests to /user/profileImage. It accepts a single request parameter, unimaginatively named "file" that's a MultipartFile object. 

That's the object that gets saved to the server.

But first, the code authenticates the user. You don't want just anybody uploading files, after all.

Once the user has been authenticated, the code handles the copy process via FileUtil.

If the copy process throws an exception, an appropriate response is sent back to the calling client. Otherwise, the client gets an OK (200) response.

Clientology

Okay, that wraps it up for the server-side tech. Now it's on to the client side.

Head over to Microsoft Visual Studio and open your CRM project. Start by adding a new model in src/app/ features/ui/model. Call it uploaded-image.ts.

export interface UploadedImage {
    file: File;
    height: number;
    width: number;
}

It's just a model that represents an image the user uploaded. The interface includes a File object as well as two numbers that reflect the dimenstions of the picture.

Next, create a service in src/app/features/ui/service. Call it image-service.ts.

import { Injectable } from '@angular/core';
import { UploadedImage } from '../model/uploaded-image';

const maxUploadSize: number = 524288;
const allowedExtensions: string[] = ['png', 'jpg', 'jpeg'];

@Injectable({ providedIn: 'root' })
export class ImageService {

    constructor() { }

    validateImage(image: UploadedImage): string {
        let imageError: string = null;
        console.log("image file name is " + image.file.name);

        if (image.file.size > maxUploadSize) {
            imageError = "Image file is too large (Max 500k)";
        } else if (image.height / image.width < 0.95 || image.height / image.width > 1.05) {
            imageError = "Please upload a square image";
        } else if (image.height > 1200) {
            imageError = "Maximum image height is 1200 pixels";
        } else if (!this.validExtension(image)) {
            imageError = "Only .jpg and .png images are allowed";
        } 

        return imageError;
    }

    validExtension(image: UploadedImage): boolean {
        let valid: boolean = false;

        for (let i = 0; i < allowedExtensions.length; i++) {
            if (image.file.name.endsWith(allowedExtensions[i])) {
                valid = true;
                break;
            }
        }

        return valid;
    }
}

The service includes a couple of convenience methods for validating images. 

Take a look at the maxUploadSize constant and where it's used and you'll notice that the client-side code also limits the file size to 500Mb. 

Why validate the file size on both the client and server side? The answer is simple: because two layers of security are better than one. That's especially true when dealing with limits on file sizes.

Also, note that the service also uses the model that you just created (UploadedImage).

And while you're messing around with services, you might as well create one that handles file uploads. Put it in src/app/features/service and name it file-upload.service.ts.

import { Injectable } from '@angular/core';
import { HttpEvent, HttpRequest, HttpErrorResponse, HttpHandler } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable({providedIn: 'root'})
export class UploadFileService {

    constructor(private handler: HttpHandler) { }

    pushFileToStorage(file: File, url: string): Observable<HttpEvent<{}>> {
        const data: FormData = new FormData();
        data.append('file', file);

        const newRequest = new HttpRequest('POST', url, data);

        return this.handler.handle(newRequest).pipe(catchError(this.handleError));
    }

    private handleError(error: HttpErrorResponse) {
        if (error.error instanceof ErrorEvent) {
            // A client-side or network error occurred. Handle it accordingly.
            console.error('An error occurred:', error.error.message);
        } else {
            // The backend returned an unsuccessful response code.
            console.error(
                `Backend returned code ${error.status}, ` +
                `body was: ${error.error}`);

            if (error.status == 403) {
                throw new Error("You are not permitted to make changes on this account");
            }

            throw new Error("Unexpected error - please try again later");
        }

        // return an observable with a user-facing error message
        return throwError("Unexpected error - please try again later");
    };
}

The pushFileToStorage() method is the one that should draw your attention. That's where all the action happens.

That method takes in a File object and a URL. It wraps the File object in a FormData object and POSTs that object to the given URL.

That URL is expected to handle file uploads. If it doesn't, the service will generate an error.

By the way, if you're unfamiliar with FormData, it's an object that enables us developers to create a variety of key/value pairs that we can send to a server as a multipart/form-data object.

That's important because, as you may recall, the User API is expecting a MultipartFile request parameter.

Componentology

Now it's time to create a couple of more componennts. The first one will be called ImageUploaderComponent. It's purpose is to handle all image uploads (not just profile pics).

Head over to your command line and go to the root where the CRM app source is located. Then, type the following:

ng g c features/ui/image-uploader

Give it some time as it creates files for the new component. Once it's finished, you should see new files in src/app/features/ui/image-uploader.

Before you can edit those files, you'll need to take care of some housekeeping. Angular probably put the ImageUploaderComponent reference in app.module.ts.

Take it out of there and move it to user.module.ts.

Next, edit image-uploader.component.ts and make it look like this:

import { Component, ViewEncapsulation, EventEmitter, Output, ViewChild, ElementRef } from '@angular/core';
import { UploadedImage } from '../model/uploaded-image';
import { ImageService } from '../service/image-service';

@Component({
  selector: 'app-image-uploader',
  encapsulation: ViewEncapsulation.None,
  templateUrl: './image-uploader.component.html',
  styleUrls: ['./image-uploader.component.css']
})
export class ImageUploaderComponent {
    public image: any;

    @Output() uploadedImage = new EventEmitter<UploadedImage>();
    @ViewChild('input') inputFile: ElementRef;

    constructor(private imageService: ImageService) { }

    fileChange(input) {
        const reader = new FileReader();
        let uploadImage = {} as UploadedImage;

        if (input.files.length) {
            const file = input.files[0];
            uploadImage.file = file;

            let emitter = this.uploadedImage;

            reader.onload = (event) => {
                let img = new Image();

                img.onload = function (scope) {
                    uploadImage.height = img.height;
                    uploadImage.width = img.width;

                    emitter.emit(uploadImage);
                }

                img.src = <string>event.target.result;
                this.image = reader.result;
            }

            if (this.imageService.validExtension(uploadImage)) {
                reader.readAsDataURL(file);
            } else {
                emitter.emit(uploadImage);
                this.removeImage();
            }
        }
    }

    removeImage():void{
        this.image = '';
    }

    clickFileInput() {
        let el: HTMLElement = this.inputFile.nativeElement;
        el.click();
    }
}

Quite a bit going on there but it's not as complicated as it looks.

When the user uploads an image, the fileChange() method gets triggered.

That method creates an UploadedImage object (which you've already seen) and emits it to the parent component.

If you're unfamiliar with the concept of emitting in Angular, that's how you communicate changes from child to parent components. The telltale sign that any component includes an emitter is that @Output() annotation that you see towards the top.

The clickFileInput() method is necessary so you can create a nice, pretty button for file uploads instead of relying on the default, ugly, grey button HTML uses for file uploading.

You'll see why it's necessary as you look at the code in image-uploader.component.html:

<button mat-raised-button color="primary" (click)="clickFileInput()">
  <span *ngIf="!image">Select Image</span>
  <span *ngIf="image">Change Image</span>
</button>
<input type="file" id="file-upload" (change)="fileChange(input)" #input />

<div class="image-wrapper">
  <img [attr.src]="image" *ngIf="image" style="width:200px">
  <img src="assets/img/app/no-image.png" *ngIf="!image" style="width:200px">
</div>

First of all, notice that the code uses Angular Material buttons (mat-raised-button). The text in the top button is dependent on whether or not the user already uploaded an image.

If the user hasn't yet uploaded an image, the text reads Select Image. Otherwise, the text reads Change Image.

If the user clicks the button, the aforementioned clickFileInput() method is triggered.

Again, that's so you can create a pretty Angular Material button instead of relying on the ugly HTML file input button.

Speaking of that ugly button, you'll see it just below the Angular Material button. It's the <input type="file"> element.

However, it's hidden! At least it will be when you update image-uploader.component.css.

input[type="file"] {
  visibility: hidden;
}

.image-wrapper {
  margin-top: 20px;
  margin-bottom: 30px;
}

That top block is the part that hides the default HTML file upload button so you can give your users a more pleasant experience.

We aim to make folks happy around here.

Anyhoo, the clickFileInput() method forces a click on the <input> element. That click, in turn, brings up the file upload dialog.

Then the user can navigate through the operating system to find just the right image to upload. 

It's a wonderful process that you've probably experienced countless times.

Finally, note that the default image in the <img> tag is /assets/image/app/no-image.png. That's a royalty-free image I found online and uploaded to the source tree. You can grab it from GitHub or substitute your own image.

Componentology Part Deux

Now it's time for yet another component. This one will handle profile image uploads. It will also depend on the component you just created.

Go to that command line again and type this:

ng g c features/user/profile-image

Wait for the churn to finish and you should have some new files in src/app/features/user/profile-image.

Now edit profile-image.component.ts and make it look like this:

import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { HttpResponse, HttpEvent } from '@angular/common/http';
import { UploadFileService } from '../../service/file-upload.service';
import { UserService } from '../../service/user.service';
import { UploadedImage } from '../../ui/model/uploaded-image';
import { ImageService } from '../../ui/service/image-service';

const profileImageUploadUrl: string = 'http://localhost:8080/user/profileImage';

@Component({
    selector: 'app-profile-image',
    templateUrl: './profile-image.component.html',
    styleUrls: ['./profile-image.component.css'],
    encapsulation: ViewEncapsulation.None
})
export class ProfileImageComponent implements OnInit {

    currentFileUpload: UploadedImage;
    changeImage = false;
    clicked: boolean = false;
    imageError: string = null;

    constructor(private uploadService: UploadFileService, private userService: UserService,
        private imageService: ImageService) { }

    ngOnInit() { }

    change($event) {
        this.changeImage = true;
    }

    upload() {
        this.clicked = true;

        this.uploadService.pushFileToStorage(this.currentFileUpload.file, profileImageUploadUrl)
            .subscribe(event => this.handleEvent(event),
                err => this.handleError(err));
    }

    handleEvent(event: HttpEvent<{}>) {
        if (event instanceof HttpResponse) {
            let body = event.body;
            this.handleResponse(body);
        }

        this.currentFileUpload = undefined;
    }

    handleResponse(data: any) {
        console.log(data);
        this.currentFileUpload = undefined;
        this.clicked = false;
    }

    handleError(err: Error) {
      console.error("Error is", err);
      this.imageError = err.message;
      this.clicked = false;
    }

    onUploadedImage(image: UploadedImage) {
        this.imageError = this.imageService.validateImage(image);

        if (!this.imageError) {
            this.currentFileUpload = image;
        }
    }
}

Once again, a lot going on there but focus on the upload() method for a moment. That's where the component uses the dependency-injected UploadFileService to perform the upload.

The onUploadedImage() method is invoked when the user selects an image to upload from the file system. That's the method that gets triggered by the emitter in ImageUploaderComponent.

Yes, the name of the method is confusing because it's not actually triggered during an upload to the server but when the user selects an image to upload. It's based on the name of the model representing a file upload (UploadedImage).

Next, edit profile-image.component.html.

<div>
  <div>
    <h5>Profile Image</h5>
    <p>Upload a square image no smaller than 200x200.</p>
    <div *ngIf="imageError" class="errorMessage">
      <strong class="mat-error">{{imageError}}</strong>
    </div>
  </div>
  <div>
      <div>
        <app-image-uploader (uploadedImage)="onUploadedImage($event)"></app-image-uploader>
      </div>
      <div>
        <button mat-raised-button color="primary" [disabled]="!currentFileUpload" (click)="upload()">Save Image</button>
      </div>
  </div>
</div>

Pay close attention to that <app-image-uploader> element. That's the component you created in the previous section.

And, as you can see, that (uploadedImage) part binds the onUploadedImage() method to the emitter from that component as well.

Finally, the HTML includes a Save Image button that users can click when they're ready to formally send the file to the server for storage.

Not Just Looking at the Menu

Next, you need to update the menu. Head over to src/app/features/ui/model/menu.ts and add this:

      {
          displayName: 'Profile Image',
          iconName: 'image',
          route: 'user/profile-image'
      }

Click the link above to see the whole class.

But now that you've added a new route, you need to add a new route.

No, that's not a typo. You added a route in the menu, now you need to add a route in the routes array in UserModule. Here's what the array should look like now:

export const routes = [
    { path: '', pathMatch: 'full', redirectTo: 'account-info' },
    { path: 'account-info', component: AccountInfoComponent },
    { path: 'profile-image', component: ProfileImageComponent }
];

That last element in the array includes the new component you just created.

Test Tubing

The coding is done! Let's see if it works.

Your User API should already be running. If not, start it now.

Then, fire up your Angular CRM app by entering the following at the command line:

ng serve

Login with the usual credentials. You should see a new menu item under the User menu. 

 

Go ahead and click on that Profile Image menu item. Now, you should see a screen that looks like this:

 

Click the Select Image button and navigate to an image that follows the rules (square, not more than 500k). Select that image and it should appear in place of the default image on the screen.

 

Ah, good. Now click Save Image and watch wh-

Uh oh. Something went wrong. Your image didn't save.

If you check the console you'll see that you got a security error (401 - Unauthorized).

What happend? I know exactly what happened.

And I'll show you how to fix it in the next guide.

Wrapping It Up

So I leave you with a cliffhanger on this one. Don't worry, though, you'll get that image saved soon enough.

In the meantime, see what you can do to tweak the UI to support the look and feel that matches your brand.

As always, feel free to check out the code on GitHub.

Have fun!