Membuat Boilerplate Aplikasi Web Dengan Menggunakan ASP.NET Core - Bagian 4

18 Mei 202413 

Tutorial ini terdiri dari lima bagian.

Pada tutorial ini, kita akan membuat dua fitur yang lazim digunakan pada sebuah aplikasi web yaitu fitur untuk mengunggah file dan fitur untuk notifikasi email.

Fitur upload file akan menggunakan cloudlfare R2 bucket. R2 Bucket ini merupakan cloud storage yang bisa kita gunakan via aws-sdk-net, proses yang sama seperti AWS S3. Kemudian, cloud storage ini bisa digunakan secara gratis (free tier) dengan kapasitas yang cukup besar yaitu 10GB per bulan.

Fitur email pada tutorial ini akan menggunakan MailerSend. MailerSend merupakan email service yang bisa kita gunakan untuk melakukan test pada saat development karena MailerSend menyediakan kuota 3000 email gratis per bulan.

Okay, mari kita buat dua fitur ini.

File Upload

Pertama, kita buat terlebih dahulu dua file pada folder Pages yaitu FileUpload.cshtml dan FileUpload.cshtml.cs. Nantinya, kita bisa mengakses halaman dan melakukan upload file pada url /FileUpload.

Lalu, buka Pages/Shared/_Layout.cshtml dan tambahkan kode berikut agar terdapat menu file upload pada navbar.

<li class="nav-item">
  <a class="nav-link text-dark" asp-area="" asp-page="/FileUpload">File Upload</a>
</li>

Menu pada navbar akan menjadi seperti ini.

...
<ul class="navbar-nav flex-grow-1">
  <li class="nav-item">
    <a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
  </li>
  <li class="nav-item">
    <a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
  </li>
  // Penambahan Menu File Upload
  <li class="nav-item">
    <a class="nav-link text-dark" asp-area="" asp-page="/FileUpload">File Upload</a>
  </li>
  @{
  // Penambahan Menu User Management
  }
  @if (User.IsInRole("Admin"))
  {
  <li class="nav-item">
    <a class="nav-link text-dark" asp-area="" asp-page="/UserManagement/Index">User
      Management</a>
  </li>
  }
</ul>
...

Langkah berikutnya, pada file FileUpload.cshtml tambahkan kode berikut.

@page
@model BoilerplateWebApp.Pages.FileUploadModel
@{
  ViewData["Title"] = "File Upload";
}

<h1>File Upload</h1>

<form method="post" enctype="multipart/form-data">
    <div class="form-group">
        <div class="col-md-10">
            <p>Upload one or more files using this form:</p>
            <input type="file" name="files" multiple required/>
        </div>
    </div>
    <div class="form-group mt-2">
        <div class="col-md-10">
            <input type="submit" value="Upload" />
        </div>
    </div>
</form>

<table class="table mt-3">
    <thead>
        <tr>
            <th>
                Id
            </th>
            <th>
                Filename
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.UploadedFilesList)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Id)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.FileName)
                </td>
                <td>
                    <a asp-page="./FileUpload" asp-route-id="@item.Id">Download</a>
                </td>
            </tr>
        }
    </tbody>
</table>

Informasi mengenai file yang sudah diunggah serta input untuk mengunggah file terdapat pada pada file ini. Model.UploadedFilesList merupakan page model untuk informasi terkait file. Kemudian terdapat juga tombol download file.

Lalu mari kita install AWS SDK untuk .NET agar bisa berinteraksi dengan R2 Bucket melalui terminal.

dotnet add package AWSSDK.S3 --version 3.7.400

Langkah selanjutnya, tambahkan kode berikut pada file FileUpload.cshtml.cs.

using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using BoilerplateWebApp.Data;
using BoilerplateWebApp.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;

namespace BoilerplateWebApp.Pages
{
    [Authorize]
    public class FileUploadModel(ApplicationDbContext context, IConfiguration config) : PageModel
    {
        private static AmazonS3Client? S3Client;
        private readonly string UploadedFileBucketName = config["BucketName"]!;
        private readonly string SecretKey = config["SecretKey"]!;
        private readonly string AccessKey = config["AccessKey"]!;
        private readonly string AccountID = config["AccountID"]!;
        public List<UploadedFiles> UploadedFilesList = [];

        public async Task<IActionResult> OnGetAsync(Guid? id)
        {
            if (id.HasValue)
            {
                return await DownloadFile(id.Value);
            }

            // List file
            UploadedFilesList = await context.UploadedFiles.ToListAsync();

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(List<IFormFile> files)
        {
            long size = files.Sum(f => f.Length);

            InitializeS3Client();

            var uploadedFiles = new List<string>();
            foreach (var formFile in files)
            {
                if (formFile.Length > 0)
                {
                    await Console.Out.WriteLineAsync(formFile.FileName);

                    // Upload ke R2 Cloudflare
                    var fileName = Path.GetFileName(formFile.FileName);
                    var fileExtension = Path.GetExtension(fileName);

                    // Membuat unique filename untuk mencegah overwrite file dengan nama yang sama
                    var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}";

                    using var memoryStream = new MemoryStream();
                    await formFile.CopyToAsync(memoryStream);
                    memoryStream.Position = 0;

                    var putRequest = new PutObjectRequest
                    {
                        BucketName = UploadedFileBucketName,
                        Key = uniqueFileName,
                        InputStream = memoryStream,
                        ContentType = formFile.ContentType,
                        DisablePayloadSigning = true
                    };

                    try
                    {
                        var response = await S3Client!.PutObjectAsync(putRequest);
                        uploadedFiles.Add(uniqueFileName);

                        var uploadedFile = new UploadedFiles
                        {
                            Id = Guid.NewGuid(),
                            FileName = formFile.FileName,
                            FileUrl = uniqueFileName,
                        };

                        context.UploadedFiles.Add(uploadedFile);
                        await context.SaveChangesAsync();

                        Console.WriteLine($"File uploaded successfully: {uniqueFileName}");
                    }
                    catch (AmazonS3Exception ex)
                    {
                        Console.WriteLine($"Error uploading file {uniqueFileName}: {ex.Message}");
                    }

                }
            }

            return Redirect("/FileUpload");
        }

        private void InitializeS3Client()
        {
            var accessKey = AccessKey;
            var secretKey = SecretKey;
            var credentials = new BasicAWSCredentials(accessKey, secretKey);
            S3Client = new AmazonS3Client(credentials, new AmazonS3Config
            {
                ServiceURL = $"https://{AccountID}.r2.cloudflarestorage.com",
            });
        }

        private async Task<IActionResult> DownloadFile(Guid id)
        {
            var file = await context.UploadedFiles.FindAsync(id);
            if (file == null)
            {
                return NotFound($"File with ID {id} not found.");
            }

            InitializeS3Client();

            try
            {
                var request = new GetObjectRequest
                {
                    BucketName = UploadedFileBucketName,
                    Key = file.FileUrl // Assuming FileUrl contains the S3 object key
                };

                using var response = await S3Client!.GetObjectAsync(request);
                using var responseStream = response.ResponseStream;
                using var memoryStream = new MemoryStream();
                await responseStream.CopyToAsync(memoryStream);
                memoryStream.Position = 0;

                return File(memoryStream.ToArray(), response.Headers.ContentType, file.FileName);
            }
            catch (AmazonS3Exception ex)
            {
                await Console.Out.WriteLineAsync(ex.Message);
                return RedirectToPage();
            }
        }
    }
}

FileUpload.cshtml.cs merupakan proses dimana upload file berlangsung nantinya. OnGetAsync akan memunculkan daftar file yang telah diunggah. OnPostAsync melakukan proses untuk menyimpan file pada R2 Bucket dan database. Nama file diubah menjadi unik (uniqueFileName) agar tidak terjadi overwrite karena ada kesamaan nama file. InitializeS3Client() merupakan function untuk berinteraksi dengan R2 Bucket melalui AWS SDK. Kemudian DownloadFile akan bejalan ketika user melakukan download file.

Setelah itu, mari kita buat model untuk file upload. Buat sebuah folder yaitu Model dan file baru di dalamnya yaitu UploadedFilesModel.cs. Tambahkan kode berikut pada file tersebut.

namespace BoilerplateWebApp.Models
{
    public class UploadedFilesModel
    {
        public Guid Id { get; set; }
        public string FileName { get; set; } = string.Empty;
        public string FileUrl { get; set; } = string.Empty;
    }
}

Model ini merupakan struktur informasi yang akan kita simpan pada database. Buka file Data/ApplicationDBCOntext.cs lalu ubah file menjadi seperti ini.

using BoilerplateWebApp.Models;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace BoilerplateWebApp.Data;

public class ApplicationDbContext : IdentityDbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    public DbSet<UploadedFilesModel> UploadedFiles { get; set; }
}

Kita menambahkan line public DbSet<UploadedFilesModel> UploadedFiles { get; set; } agar informasi mengenai file yang sudah diunggah akan tersimpan di database.

Selanjutnya, kita harus melakukan update pada database agar terdapat table untuk informasi mengenai file yang sudah diunggah. Sebelumnya, pastikan dotnet-ef sudah terinstall. Apabila belum terdapat dotnet-ef, jalankan perintah ini untuk install dotnet-ef melalui terminal.

dotnet tool install --global dotnet-ef

Lalu, kita buat sebuah migration dengan perintah ini pada terminal.

dotnet ef migrations add AddUploadedFilesTable

Langkah selanjutnya adalah update database kita dengan menjalankan ini.

dotnet ef database update

Jika terdapat error, kawan-kawan bisa melakukan build secara clean terlebih dahulu dengan perintah ini, lalu mencoba lagi dengan membuat migration dan menjalankan database.

dotnet clean && dotnet restore && dotnet build

Proses berikutnya adalah melakukan setting pada Cloudflare R2 Bucket. Kita akan menggunakan informasi Account ID, Access Key ID, dan Secret Key ID agar kita bisa berinteraksi dengan R2 Bucket melalui AWS SDK.

Untuk melakukan setting pada R2 teman-teman bisa mengikuti langkah berikut:

  1. Login ke dashboard Cloudflare dan pilih R2.
  2. Pilih Create Bucket.
  3. Masukan nama boilerplate-aspdotnet-core (teman-teman juga bisa menggunakan nama yang lain).
  4. Copy Account ID yang ada di dashboard.
  5. Lalu pilih Manage R2 API Tokens.
  6. Kemudian Pilih Create API Token.
  7. Pada dialog untuk API Token, isi Token Name menjadi boilerplate-aspdotnet-core-token dan set permissions menjadi Admin Read & Write.
  8. Selanjutnya pilih Create API Token
  9. Copy Access Key ID dan Secret Access Key. Nantinya, informasi ini akan kita gunakan bersama Account ID (Langkah keempat).

Setelah setting R2, mari kita tambahkan informasi Account ID, Access Key ID, dan Secret Key ID pada project kita melalui terminal dengan perintah berikut ini. Informasi ini hanya digunakan pada saat proses development saja. Untuk versi production, kita bisa gunakan metode lain untuk menyimpan informasi ini (dibahas pada tutorial bagian 5).

dotnet user-secrets set "BucketName" "boilerplate-aspdotnet-core" && \
dotnet user-secrets set "SecretKey" "<Ganti sesuai Secret Access Key>" && \
dotnet user-secrets set "AccessKey" "<Ganti sesuai Access Key ID>" && \
dotnet user-secrets set "AccountID" "<Ganti sesuai Account ID>"

Mari kita coba fitur upload ini dengan menjalankan aplikasinya.

dotnet watch

Silakan teman-teman mengunjungi /FileUpload dan melakukan proses upload file. Setelah upload, file akan muncul pada daftar yang di halaman File Upload. Ketika teman-teman klik download, proses download akan langsung berjalan. Kemudian, file yang telah diunggah bisa teman-teman lihat di Dashboard Cloudflare R2.

Cloudflare R2

Berikut ini merupakan contoh file-file yang sudah diunggah ke R2 Bucket melalui aplikasi ini.

Notifikasi Email

Mari kita setting MailerSend sebagai email service aplikasi ini.

  1. Login ke dashboard MailerSend (teman-teman bisa daftar terlebih dahulu kalau belum punya akun MailerSend).
  2. Pilih menu Domain.
  3. Pada informasi domain, klik trial domain yang telah disediakan oleh MailerSend. Kita akan menggunakan domain ini untuk mengirim email.
  4. Selanjutnya pilih Generate Token dan beri nama token menjadi boilerplate-email-dev serta pilih permission level Full Access.
  5. Pilih Create Token dan copy API Token yang muncul karena akan kita gunakan nanti.

Selanjutnya, mari kita tambahkan informasi ini sebagai user-secrets pada project ini melalui terminal

dotnet user-secrets set "MailerSendToken" <Ganti sesuai MailerSend API Token>

Langkah berikutnya adalah integrasi email service ini pada proses registrasi user. Saat ini, aplikasi kita akan melakukan redirect ke halaman Register confirmation setelah user melakukan registrasi. Kita akan menonaktifkan halaman ini sehingga user hanya bisa mengaktifkan akunnya dengan mengunjungi verification link yang dikirmkan melalui email.

Buka file Areas\Identity\Pages\Account\RegisterConfirmation.cshtml.cs. Ubah pada kode di line 63 dengan mengganti value DisplayConfirmAccountLink dari true menjadi false.

// Once you add a real email sender, you should remove this code that lets you confirm the account
DisplayConfirmAccountLink = false; // Mengubah value menjadi false

Setelah itu, tambahkan function berikut ke dalam file Areas\Identity\Pages\Account\Register.cshtml.cs.

private async Task SendEmailAsync(string email, string subject, string htmlMessage)
{
    var client = _httpClientFactory.CreateClient();

    var request = new HttpRequestMessage(HttpMethod.Post, "https://api.mailersend.com/v1/email");
    request.Headers.Add("X-Requested-With", "XMLHttpRequest");
    request.Headers.Add("Authorization", $"Bearer {_configuration["MailerSendToken"]}");

    var emailData = new
    {
        from = new { email = "[email protected]" },
        to = new[] { new { email = email } },
        subject = subject,
        text = htmlMessage,
        html = htmlMessage
    };

    var json = JsonSerializer.Serialize(emailData);
    request.Content = new StringContent(json, Encoding.UTF8, "application/json");

    var response = await client.SendAsync(request);
    response.EnsureSuccessStatusCode();
}

Kemudian pada file yang sama, ubah function OnPostAsync dan tambahkan kode berikut.

...
    // kode sebelumnya

    // await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
    //     $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

    // Penggunaan MailerSend
    await SendEmailAsync(Input.Email, "Confirm your email",
        $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

    if (_userManager.Options.SignIn.RequireConfirmedAccount)
    {
        return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
    }
    else
    {
        await _signInManager.SignInAsync(user, isPersistent: false);
        return LocalRedirect(returnUrl);
    }
}

Berikut hasil akhir file Areas\Identity\Pages\Account\Register.cshtml.cs.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;

namespace BoilerplateWebApp.Areas.Identity.Pages.Account
{
    public class RegisterModel : PageModel
    {
        private readonly SignInManager<IdentityUser> _signInManager;
        private readonly UserManager<IdentityUser> _userManager;
        private readonly IUserStore<IdentityUser> _userStore;
        private readonly IUserEmailStore<IdentityUser> _emailStore;
        private readonly ILogger<RegisterModel> _logger;
        private readonly IEmailSender _emailSender;
        private readonly IHttpClientFactory _httpClientFactory;
        private readonly IConfiguration _configuration;

        public RegisterModel(
            UserManager<IdentityUser> userManager,
            IUserStore<IdentityUser> userStore,
            SignInManager<IdentityUser> signInManager,
            ILogger<RegisterModel> logger,
            IEmailSender emailSender,
            IHttpClientFactory httpClientFactory,
            IConfiguration configuration)
        {
            _userManager = userManager;
            _userStore = userStore;
            _emailStore = GetEmailStore();
            _signInManager = signInManager;
            _logger = logger;
            _emailSender = emailSender;
            _httpClientFactory = httpClientFactory;
            _configuration = configuration;
        }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        [BindProperty]
        public InputModel Input { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public string ReturnUrl { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public IList<AuthenticationScheme> ExternalLogins { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public class InputModel
        {
            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [Required]
            [EmailAddress]
            [Display(Name = "Email")]
            public string Email { get; set; }

            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [Required]
            [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
            [DataType(DataType.Password)]
            [Display(Name = "Password")]
            public string Password { get; set; }

            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [DataType(DataType.Password)]
            [Display(Name = "Confirm password")]
            [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
            public string ConfirmPassword { get; set; }
        }


        public async Task OnGetAsync(string returnUrl = null)
        {
            ReturnUrl = returnUrl;
            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
        }

        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
        {
            returnUrl ??= Url.Content("~/");
            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
            if (ModelState.IsValid)
            {
                var user = CreateUser();

                await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
                await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
                var result = await _userManager.CreateAsync(user, Input.Password);
                await _userManager.AddToRoleAsync(user, "User");

                if (result.Succeeded)
                {
                    _logger.LogInformation("User created a new account with password.");

                    var userId = await _userManager.GetUserIdAsync(user);
                    var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                    code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                    var callbackUrl = Url.Page(
                        "/Account/ConfirmEmail",
                        pageHandler: null,
                        values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
                        protocol: Request.Scheme);

                    // await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                    //     $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                    await SendEmailAsync(Input.Email, "Confirm your email",
                        $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                    if (_userManager.Options.SignIn.RequireConfirmedAccount)
                    {
                        return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
                    }
                    else
                    {
                        await _signInManager.SignInAsync(user, isPersistent: false);
                        return LocalRedirect(returnUrl);
                    }
                }
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, error.Description);
                }
            }

            // If we got this far, something failed, redisplay form
            return Page();
        }

        private IdentityUser CreateUser()
        {
            try
            {
                return Activator.CreateInstance<IdentityUser>();
            }
            catch
            {
                throw new InvalidOperationException($"Can't create an instance of '{nameof(IdentityUser)}'. " +
                    $"Ensure that '{nameof(IdentityUser)}' is not an abstract class and has a parameterless constructor, or alternatively " +
                    $"override the register page in /Areas/Identity/Pages/Account/Register.cshtml");
            }
        }

        private IUserEmailStore<IdentityUser> GetEmailStore()
        {
            if (!_userManager.SupportsUserEmail)
            {
                throw new NotSupportedException("The default UI requires a user store with email support.");
            }
            return (IUserEmailStore<IdentityUser>)_userStore;
        }

        private async Task SendEmailAsync(string email, string subject, string htmlMessage)
        {
            var client = _httpClientFactory.CreateClient();

            var request = new HttpRequestMessage(HttpMethod.Post, "https://api.mailersend.com/v1/email");
            request.Headers.Add("X-Requested-With", "XMLHttpRequest");
            request.Headers.Add("Authorization", $"Bearer {_configuration["MailerSendToken"]}");

            var emailData = new
            {
                from = new { email = "[email protected]" },
                to = new[] { new { email = email } },
                subject = subject,
                text = htmlMessage,
                html = htmlMessage
            };

            var json = JsonSerializer.Serialize(emailData);
            request.Content = new StringContent(json, Encoding.UTF8, "application/json");

            var response = await client.SendAsync(request);
            response.EnsureSuccessStatusCode();
        }
    }
}

SendEmailAsync berisi proses untuk mengirimkan email ke API MailerSend melalui HTTP POST ke endpoint https://api.mailersend.com/v1/email.

Kemudian, buka Program.cs dan tambahkan kode HTTP Client.

...
builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>() // Menambahkan role services
    .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();
builder.Services.AddHttpClient(); // Menambahkan HTTP Client

var app = builder.Build();
...

Okay, sekarang mari kita jalankan aplikasi ini.

dotnet watch

Teman-teman bisa menuju halaman register dan tambahkan user baru. Setelah proses registrasi selesai, akan muncul halaman Register Confirmation serta email yang sudah dikirimkan ke user.

Register Confirmation

Berikut contoh email yang dikirimkan kepada user.

Register Confirmation Email

Ketika user klik link yang ada pada email, maka user tersebut diaktifkan akunnya sehingga bisa melakukan login pada aplikasi.


Saat ini boilerplate ini sudah mempunyai dua fitur tambahan yaitu upload file dan notifikasi email. Kedua fitur ini merupakan fitur esensial pada sebuah aplikasi. Teman-teman bisa melakukan proses lain dengan menggunakan kode pada tutorial ini misalnya menambahkan fitur user profile picture, forgot password, atau melakukan notifikasi aktivitas pada user melalui email.

Pada bagian selanjutnya, kita akan melakukan proses akhir yaitu deployment untuk aplikasi sehingga aplikasi boilerplate ini menjadi siap untuk dirilis kepada user nantinya.

Happy Programming!

Source Code Tutorial - Github DevKage
Suka konten ini? ❤️ Dukung DevKage melalui Saweria.