Building a Messenger App: Access Page

This post is the 7th on a series:

Now that we’re done with the backend, lets move to the frontend. I will go with a single-page application.

Lets start by creating a file static/index.html with the following content.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Messenger</title>
    <link rel="shortcut icon" href="data:,">
    <link rel="stylesheet" href="/styles.css">
    <script src="/main.js" type="module"></script>
</head>
<body></body>
</html>

This HTML file must be server for every URL and JavaScript will take care of rendering the correct page.

So lets go the the main.go for a moment and in the main() function add the following route:

router.Handle("GET", "/...", http.FileServer(SPAFileSystem{http.Dir("static")}))
type SPAFileSystem struct {
	fs http.FileSystem
}

func (spa SPAFileSystem) Open(name string) (http.File, error) {
	f, err := spa.fs.Open(name)
	if err != nil {
		return spa.fs.Open("index.html")
	}
	return f, nil
}

We use a custom file system so instead of returning 404 Not Found for unknown URLs, it serves the index.html.

Router

In the index.html we loaded two files: styles.css and main.js. I leave styling to your taste.

Lets move to main.js. Create a static/main.js file with the following content:

import { guard } from './auth.js'
import Router from './router.js'

let currentPage
const disconnect = new CustomEvent('disconnect')
const router = new Router()

router.handle('/', guard(view('home'), view('access')))
router.handle('/callback', view('callback'))
router.handle(/^\/conversations\/([^\/]+)$/, guard(view('conversation'), view('access')))
router.handle(/^\//, view('not-found'))

router.install(async result => {
    document.body.innerHTML = ''
    if (currentPage instanceof Node) {
        currentPage.dispatchEvent(disconnect)
    }
    currentPage = await result
    if (currentPage instanceof Node) {
        document.body.appendChild(currentPage)
    }
})

function view(pageName) {
    return (...args) => import(`/pages/${pageName}-page.js`)
        .then(m => m.default(...args))
}

If you are follower of this blog, you already know how this works. That router is the one showed here. Just download it from @nicolasparada/router and save it to static/router.js.

We registered four routes. At the root / we show the home or access page whether the user is authenticated. At /callback we show the callback page. On /conversations/{conversationID} we show the conversation or access page whether the user is authenticated and for every other URL, we show a not found page.

We tell the router to render the result to the document body and dispatch a disconnect event to each page before leaving.

We have each page in a different file and we import them with the new dynamic import().

Auth

guard() is a function that given two functions, executes the first one if the user is authenticated, or the sencond one if not. It comes from auth.js so lets create a static/auth.js file with the following content:

export function isAuthenticated() {
    const token = localStorage.getItem('token')
    const expiresAtItem = localStorage.getItem('expires_at')
    if (token === null || expiresAtItem === null) {
        return false
    }

    const expiresAt = new Date(expiresAtItem)
    if (isNaN(expiresAt.valueOf()) || expiresAt <= new Date()) {
        return false
    }

    return true
}

export function guard(fn1, fn2) {
    return (...args) => isAuthenticated()
        ? fn1(...args)
        : fn2(...args)
}

export function getAuthUser() {
    if (!isAuthenticated()) {
        return null
    }

    const authUser = localStorage.getItem('auth_user')
    if (authUser === null) {
        return null
    }

    try {
        return JSON.parse(authUser)
    } catch (_) {
        return null
    }
}

isAuthenticated() checks for token and expires_at from localStorage to tell if the user is authenticated. getAuthUser() gets the authenticated user from localStorage.

When we login, we’ll save all the data to localStorage so it will make sense.

Access Page

access page screenshot

Lets start with the access page. Create a file static/pages/access-page.js with the following content:

const template = document.createElement('template')
template.innerHTML = `
    <h1>Messenger</h1>
    <a href="/api/oauth/github" onclick="event.stopPropagation()">Access with GitHub</a>
`

export default function accessPage() {
    return template.content
}

Because the router intercepts all the link clicks to do its navigation, we must prevent the event propagation for this link in particular.

Clicking on that link will redirect us to the backend, then to GitHub, then to the backend and then to the frontend again; to the callback page.

Callback Page

Create the file static/pages/callback-page.js with the following content:

import http from '../http.js'
import { navigate } from '../router.js'

export default async function callbackPage() {
    const url = new URL(location.toString())
    const token = url.searchParams.get('token')
    const expiresAt = url.searchParams.get('expires_at')

    try {
        if (token === null || expiresAt === null) {
            throw new Error('Invalid URL')
        }

        const authUser = await getAuthUser(token)

        localStorage.setItem('auth_user', JSON.stringify(authUser))
        localStorage.setItem('token', token)
        localStorage.setItem('expires_at', expiresAt)
    } catch (err) {
        alert(err.message)
    } finally {
        navigate('/', true)
    }
}

function getAuthUser(token) {
    return http.get('/api/auth_user', { authorization: `Bearer ${token}` })
}

The callback page doesn’t render anything. It’s an async function that does a GET request to /api/auth_user using the token from the URL query string and saves all the data to localStorage. Then it redirects to /.

HTTP

There is an HTTP module. Create a static/http.js file with the following content:

import { isAuthenticated } from './auth.js'

async function handleResponse(res) {
    const body = await res.clone().json().catch(() => res.text())

    if (res.status === 401) {
        localStorage.removeItem('auth_user')
        localStorage.removeItem('token')
        localStorage.removeItem('expires_at')
    }

    if (!res.ok) {
        const message = typeof body === 'object' && body !== null && 'message' in body
            ? body.message
            : typeof body === 'string' && body !== ''
                ? body
                : res.statusText
        throw Object.assign(new Error(message), {
            url: res.url,
            statusCode: res.status,
            statusText: res.statusText,
            headers: res.headers,
            body,
        })
    }

    return body
}

function getAuthHeader() {
    return isAuthenticated()
        ? { authorization: `Bearer ${localStorage.getItem('token')}` }
        : {}
}

export default {
    get(url, headers) {
        return fetch(url, {
            headers: Object.assign(getAuthHeader(), headers),
        }).then(handleResponse)
    },

    post(url, body, headers) {
        const init = {
            method: 'POST',
            headers: getAuthHeader(),
        }
        if (typeof body === 'object' && body !== null) {
            init.body = JSON.stringify(body)
            init.headers['content-type'] = 'application/json; charset=utf-8'
        }
        Object.assign(init.headers, headers)
        return fetch(url, init).then(handleResponse)
    },

    subscribe(url, callback) {
        const urlWithToken = new URL(url, location.origin)
        if (isAuthenticated()) {
            urlWithToken.searchParams.set('token', localStorage.getItem('token'))
        }
        const eventSource = new EventSource(urlWithToken.toString())
        eventSource.onmessage = ev => {
            let data
            try {
                data = JSON.parse(ev.data)
            } catch (err) {
                console.error('could not parse message data as JSON:', err)
                return
            }
            callback(data)
        }
        const unsubscribe = () => {
            eventSource.close()
        }
        return unsubscribe
    },
}

This module is a wrapper around the fetch and EventSource APIs. The most important part is that it adds the JSON web token to the requests.

Home Page

home page screenshot

So, when the user login, the home page will be shown. Create a static/pages/home-page.js file with the following content:

import { getAuthUser } from '../auth.js'
import { avatar } from '../shared.js'

export default function homePage() {
    const authUser = getAuthUser()
    const template = document.createElement('template')
    template.innerHTML = `
        <div>
            <div>
                ${avatar(authUser)}
                <span>${authUser.username}</span>
            </div>
            <button id="logout-button">Logout</button>
        </div>
        <!-- conversation form here -->
        <!-- conversation list here -->
    `
    const page = template.content
    page.getElementById('logout-button').onclick = onLogoutClick
    return page
}

function onLogoutClick() {
    localStorage.clear()
    location.reload()
}

For this post, this is the only content we render on the home page. We show the current authenticated user and a logout button.

When the user clicks to logout, we clear all inside localStorage and do a reload of the page.

Avatar

That avatar() function is to show the user’s avatar. Because it’s used in more than one place, I moved it to a shared.js file. Create the file static/shared.js with the following content:

export function avatar(user) {
    return user.avatarUrl === null
        ? `<figure class="avatar" data-initial="${user.username[0]}"></figure>`
        : `<img class="avatar" src="${user.avatarUrl}" alt="${user.username}'s avatar">`
}

We use a small figure with the user’s initial in case the avatar URL is null.

You can show the initial with a little of CSS using the attr() function.

.avatar[data-initial]::after {
    content: attr(data-initial);
}

Development Login

access page with login form screenshot

In the previous post we coded a login for development. Lets add a form for that in the access page. Go to static/pages/access-page.js and modify it a little.

import http from '../http.js'

const template = document.createElement('template')
template.innerHTML = `
    <h1>Messenger</h1>
    <form id="login-form">
        <input type="text" placeholder="Username" required>
        <button>Login</button>
    </form>
    <a href="/api/oauth/github" onclick="event.stopPropagation()">Access with GitHub</a>
`

export default function accessPage() {
    const page = template.content.cloneNode(true)
    page.getElementById('login-form').onsubmit = onLoginSubmit
    return page
}

async function onLoginSubmit(ev) {
    ev.preventDefault()

    const form = ev.currentTarget
    const input = form.querySelector('input')
    const submitButton = form.querySelector('button')

    input.disabled = true
    submitButton.disabled = true

    try {
        const payload = await login(input.value)
        input.value = ''

        localStorage.setItem('auth_user', JSON.stringify(payload.authUser))
        localStorage.setItem('token', payload.token)
        localStorage.setItem('expires_at', payload.expiresAt)

        location.reload()
    } catch (err) {
        alert(err.message)
        setTimeout(() => {
            input.focus()
        }, 0)
    } finally {
        input.disabled = false
        submitButton.disabled = false
    }
}

function login(username) {
    return http.post('/api/login', { username })
}

I added a login form. When the user submits the form. It does a POST requets to /api/login with the username. Saves all the data to localStorage and reloads the page.

Remember to remove this form once you are done with the frontend.


That’s all for this post. In the next one, we’ll continue with the home page to add a form to start conversations and display a list with the latest ones.

Souce Code

Discuss on Twitter