In the previous example we've made our todo app installable and now we'll provide authentication mechanisms. As always we're starting with the manifest and now we'll need to activate the auth feature of prest:

Cargo.toml:6

...
prest = { version = "0.2", features = ["auth"] }
...

Everything else remains just like in the previous one, and you can find it's full content at the end of this tutorial. Same with the build script and the library as they remain untouched. Since authentication is the server-side business we'll only need to modify our binary:

src/main.rs

use prest::*;
use todo_pwa_auth::{into_page, shared_routes};

embed_build_output_as!(BuiltAssets);

#[derive(Table, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
struct Todo {
    #[serde(default = "Uuid::new_v4")]
    pub id: Uuid,
    #[serde(default)]
    pub owner: Uuid,
    pub task: String,
    pub done: bool,
}

fn main() {
    Todo::migrate();
    shared_routes()
        .route("/todos", get(todos).put(add).patch(toggle).delete(delete))
        .wrap_non_htmx(into_page)
        .embed(BuiltAssets)
        .run();
}

async fn todos(auth: Auth) -> Markup {
    if let Some(user) = auth.user {
        html!(
            form hx-put="/todos" hx-target="div" hx-swap="beforeend" hx-on--after-request="this.reset()" {
                input."input input-bordered input-primary" type="text" name="task" {}
                button."btn btn-outline btn-primary ml-4" type="submit" {"Add"}
            }
            ."w-full" {@for todo in Todo::find_by_owner(&user.id) {(todo)}}
        )
    } else {
        html!(
            @if *WITH_GOOGLE_AUTH {
                a."btn btn-ghost" href="/auth/google" {"Login with Google"}
                ."divider" {"OR"}
            }
            ."flex" {
                ."bg-base-100 border-base-300 rounded-box p-6" {
                    form."flex flex-col gap-4 items-center" method="POST" action="/auth/username_password/signin" {
                        input."input input-bordered input-primary" type="text" name="username" placeholder="username" {}
                        input."input input-bordered input-primary" type="password" name="password" placeholder="password" {}
                        button."btn btn-outline btn-primary ml-4" type="submit" {"Sign in"}
                    }
                }
                ."divider divider-horizontal" {}
                ."bg-base-100 border-base-300 rounded-box p-6" {
                    form."flex flex-col gap-4 items-center" method="POST" action="/auth/username_password/signup" {
                        input."input input-bordered input-primary" type="text" name="username" placeholder="username" {}
                        input."input input-bordered input-primary" type="password" name="password" placeholder="password" {}
                        button."btn btn-outline btn-primary ml-4" type="submit" {"Sign up"}
                    }
                }
            }
        )
    }
}

async fn add(user: User, Form(mut todo): Form<Todo>) -> Markup {
    todo.owner = user.id;
    todo.save().unwrap().render()
}

async fn toggle(user: User, Form(mut todo): Form<Todo>) -> impl IntoResponse {
    if todo.owner == user.id {
        todo.update_done(!todo.done)
            .unwrap()
            .render()
            .into_response()
    } else {
        StatusCode::UNAUTHORIZED.into_response()
    }
}
async fn delete(user: User, Form(todo): Form<Todo>) -> impl IntoResponse {
    if todo.owner == user.id {
        todo.remove().unwrap();
        StatusCode::OK
    } else {
        StatusCode::UNAUTHORIZED
    }
}

impl Render for Todo {
    fn render(&self) -> Markup {
        html! {
            ."flex items-center" hx-target="this" hx-swap="outerHTML" hx-vals=(json!(self)) {
                input."toggle toggle-primary" type="checkbox" hx-patch="/todos" checked[self.done] {}
                label."ml-4 text-lg" {(self.task)}
                button."btn btn-ghost ml-auto" hx-delete="/todos" {"Delete"}
            }
        }
    }
}

Now our handlers will need to consider whether requests are authorized to read/write specific todos and their code becomes significantly larger, so we'll be moving away from closures and use functions. Also, here we introduced two new extractors:

Also, our todos handler got new templates with sign in / sign up forms, and an optional login with google button that renders depending on whether GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env variables required for the auth are provided. By default after the auth flow they will redirect back to the /, but this behaviour can be customized by sending redirect field with the forms or by adding next query param to the google auth route.

Handlers for the routes specified in these forms and the button are automatically appended to the router in the .run() function. It will also set up the session and user management middleware, storage for them and other required utils.

That's it! Now users can install the app and handle their own todos without spying on each other. But maybe you actually want todos to be public? Let's make it happen in the next example.

Remaining code used in this example:

Cargo.toml

[package]
name = "todo-pwa-auth"
edition = "2021"

[dependencies]
prest = { version = "0.2", features = ["auth"] }
serde = { version = "1", features = ["derive"] }
wasm-bindgen = "0.2"

[build-dependencies]
prest-build = "0.2"

build.rs

use prest_build::*;
fn main() {
    build_pwa(PWAOptions::default()).unwrap();
}

src/lib.rs

use prest::*;

pub fn shared_routes() -> Router {
    route("/", get(home))
}

async fn home() -> Markup {
    into_page(html!(
        span."loading loading-spinner loading-lg" hx-get="/todos" hx-trigger="load" hx-swap="outerHTML" hx-push-url="true"
            hx-on--after-request="if (!event.detail.successful) { document.getElementById('alert').style.display = 'flex'; this.remove() }" {}
        div #"alert" role="alert" class="alert alert-error justify-center" style="display: none;" {"Couldn't fetch the todos :("}
    ))
    .await
}

pub async fn into_page(content: Markup) -> Markup {
    html! { html data-theme="dark" { 
        (Head::with_title("Todo app"))
        body."max-w-screen-sm mx-auto mt-12 flex flex-col items-center" {
            (content)
            (Scripts::default())
        }
    }}
}

#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(start)]
pub fn main() {
    shared_routes().handle_fetch_events()
}