add(ui): ability to log out

This commit is contained in:
trisua 2025-03-23 16:37:43 -04:00
parent d2ca9e23d3
commit b3cac5f97a
29 changed files with 499 additions and 124 deletions

View file

@ -8,6 +8,7 @@ use std::{
use tera::Context;
use tetratto_core::{config::Config, model::auth::User};
use tetratto_l10n::LangFile;
use tetratto_shared::hash::salt;
use tokio::sync::RwLock;
use crate::{create_dir_if_not_exists, write_if_track, write_template};
@ -21,8 +22,9 @@ pub const FAVICON: &str = include_str!("./public/images/favicon.svg");
pub const STYLE_CSS: &str = include_str!("./public/css/style.css");
// js
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
pub const ME_JS: &str = include_str!("./public/js/me.js");
// html
pub const ROOT: &str = include_str!("./public/html/root.html");
@ -165,11 +167,15 @@ pub(crate) async fn init_dirs(config: &Config) {
write_template!(langs_path->"en-US.toml"(LANG_EN_US));
}
/// A random ASCII value inserted into the URL of static assets to "break" the cache. Essentially just for cache busting.
pub(crate) static CACHE_BREAKER: LazyLock<String> = LazyLock::new(|| salt());
/// Create the initial template context.
pub(crate) fn initial_context(config: &Config, lang: &LangFile, user: &Option<User>) -> Context {
let mut ctx = Context::new();
ctx.insert("config", &config);
ctx.insert("user", &user);
ctx.insert("lang", &lang);
ctx.insert("lang", &lang.data);
ctx.insert("random_cache_breaker", &CACHE_BREAKER.clone());
ctx
}

View file

@ -2,5 +2,15 @@ name = "com.tetratto.langs:en-US"
version = "1.0.0"
[data]
"general:action.login" = "Login"
"general:action.register" = "Register"
"general:link.home" = "Home"
"dialog:action.okay" = "Ok"
"dialog:action.continue" = "Continue"
"dialog:action.cancel" = "Cancel"
"dialog:action.yes" = "Yes"
"dialog:action.no" = "No"
"auth:action.login" = "Login"
"auth:action.register" = "Register"
"auth:action.logout" = "Logout"
"auth:link.my_profile" = "My profile"

View file

@ -55,7 +55,7 @@ macro_rules! create_dir_if_not_exists {
#[macro_export]
macro_rules! get_user_from_token {
(($jar:ident, $db:expr) <optional>) => {{
($jar:ident, $db:expr) => {{
if let Some(token) = $jar.get("__Secure-atto-token") {
match $db
.get_user_by_token(&tetratto_shared::hash::hash(
@ -70,17 +70,6 @@ macro_rules! get_user_from_token {
None
}
}};
($jar:ident, $db:ident) => {{
if let Some(token) = $jar.get("__Secure-Atto-Token") {
match $db.get_user_by_token(token) {
Ok(ua) => ua,
Err(_) => return axum::response::Html(crate::data::assets::REDIRECT_TO_AUTH),
}
} else {
None
}
}};
}
#[macro_export]

View file

@ -318,6 +318,27 @@ table ol {
background: var(--color-surface);
}
.card-nest {
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
var(--color-shadow);
border-radius: var(--radius);
}
.card-nest .card {
box-shadow: 0;
}
.card-nest > .card:first-child {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
background: var(--color-super-raised);
}
.card-nest > .card:last-child {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* buttons */
button,
.button {
@ -616,6 +637,11 @@ dialog[open] {
display: block;
}
dialog::backdrop {
background: hsla(0, 0%, 0%, 50%);
backdrop-filter: blur(5px);
}
/* dropdown */
.dropdown {
position: relative;

View file

@ -1,7 +1,7 @@
{% extends "auth/base.html" %} {% block head %}
<title>Login</title>
{% endblock %} {% block title %}Login{% endblock %} {% block content %}
<form class="w-full flex flex-col gap-4">
<form class="w-full flex flex-col gap-4" onsubmit="login(event)">
<div class="flex flex-col gap-1">
<label for="username"><b>Username</b></label>
<input
@ -26,6 +26,35 @@
<button>Submit</button>
</form>
<script>
function login(e) {
e.preventDefault();
fetch("/api/v1/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: e.target.username.value,
password: e.target.password.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "sucesss" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = "/";
}, 150);
}
});
}
</script>
{% endblock %} {% block footer %}
<span class="small w-full text-center"
>Or, <a href="/auth/register">register</a></span

View file

@ -12,7 +12,7 @@
class="button {% if selected == 'home' %}active{% endif %}"
>
{{ icon "house" }}
<span class="desktop">Home</span>
<span class="desktop">{{ text "general:link.home" }}</span>
</a>
{% endif %}
</div>
@ -30,6 +30,20 @@
{{ macros::avatar(username=user.username, size="24px") }}
{{ icon "chevron-down" c(dropdown-arrow) }}
</button>
<div class="inner">
<b class="title">{{ user.username }}</b>
<a href="/@{{ user.username }}">
{{ icon "book-heart" }}
<span>{{ text "auth:link.my_profile" }}</span>
</a>
<div class="title"></div>
<button class="red" onclick="trigger('me::logout')">
{{ icon "log-out" }}
<span>{{ text "auth:action.logout" }}</span>
</button>
</div>
</div>
{% else %}
<div class="dropdown">
@ -44,11 +58,11 @@
<div class="inner">
<a href="/auth/login" class="button">
{{ icon "log-in" }}
<span>Login</span>
<span>{{ text "auth:action.login" }}</span>
</a>
<a href="/auth/register" class="button">
{{ icon "user-plus" }}
<span>Register</span>
<span>{{ text "auth:action.register" }}</span>
</a>
</div>
</div>

View file

@ -2,22 +2,12 @@
{{ macros::nav(selected="home") }}
<main class="flex flex-col gap-2">
<h1>Hello, world!</h1>
<div class="pillmenu">
<a class="active" href="#">A</a>
<a href="#">B</a>
<a href="#">C</a>
</div>
<div class="card w-full flex flex-col gap-2">
<div class="flex gap-2 flex-wrap">
<button>Hello, world!</button>
<button class="secondary">Hello, world!</button>
<button class="camo">Hello, world!</button>
<div class="card-nest">
<div class="card">
<b>✨ Welcome to <i>{{ config.name }}</i>!</b>
</div>
<input type="text" placeholder="abcd" />
<div class="card">We're still working on your feed...</div>
</div>
</main>
{% endblock %}

View file

@ -22,6 +22,7 @@
globalThis.ns_config = {
root: "/js/",
verbose: globalThis.ns_verbose,
version: "cache-breaker-{{ random_cache_breaker }}",
};
globalThis._app_base = {
@ -76,5 +77,141 @@
atto["hooks::partial_embeds"]();
});
</script>
<!-- dialogs -->
<dialog id="link_filter">
<div class="inner">
<p>Pressing continue will bring you to the following URL:</p>
<pre><code id="link_filter_url"></code></pre>
<p>Are sure you want to go there?</p>
<hr />
<div class="flex gap-2">
<a
class="button primary bold"
id="link_filter_continue"
rel="noopener noreferrer"
target="_blank"
onclick="document.getElementById('link_filter').close()"
>
{{ text "dialog:action.continue" }}
</a>
<button
class="bold"
type="button"
onclick="document.getElementById('link_filter').close()"
>
{{ text "dialog:action.cancel" }}
</button>
</div>
</div>
</dialog>
<dialog id="web_api_prompt">
<div class="inner flex flex-col gap-2">
<form
class="flex gap-2 flex-col"
onsubmit="event.preventDefault()"
>
<label for="prompt" id="web_api_prompt:msg"></label>
<input id="prompt" name="prompt" />
<div class="flex justify-between">
<div></div>
<div class="flex gap-2">
<button
class="primary bold circle"
onclick="globalThis.web_api_prompt_submit(document.getElementById('prompt').value); document.getElementById('prompt').value = ''"
type="button"
>
{{ icon "check" }} {{ text "dialog:action.okay"
}}
</button>
<button
class="bold red camo"
onclick="globalThis.web_api_prompt_submit('')"
type="button"
>
{{ icon "x" }} {{ text "dialog:action.cancel" }}
</button>
</div>
</div>
</form>
</div>
</dialog>
<dialog id="web_api_prompt_long">
<div class="inner flex flex-col gap-2">
<form
class="flex gap-2 flex-col"
onsubmit="event.preventDefault()"
>
<label
for="prompt_long"
id="web_api_prompt_long:msg"
></label>
<textarea id="prompt_long" name="prompt_long"></textarea>
<div class="flex justify-between">
<div></div>
<div class="flex gap-2">
<button
class="primary bold circle"
onclick="globalThis.web_api_prompt_long_submit(document.getElementById('prompt_long').value); document.getElementById('prompt_long').value = ''"
type="button"
>
{{ icon "check" }} {{ text "dialog:action.okay"
}}
</button>
<button
class="bold red camo"
onclick="globalThis.web_api_prompt_long_submit('')"
type="button"
>
{{ icon "x" }} {{ text "dialog:action.cancel" }}
</button>
</div>
</div>
</form>
</div>
</dialog>
<dialog id="web_api_confirm">
<div class="inner flex flex-col gap-2">
<form
class="flex gap-2 flex-col"
onsubmit="event.preventDefault()"
>
<label id="web_api_confirm:msg"></label>
<div class="flex justify-between">
<div></div>
<div class="flex gap-2">
<button
class="primary bold circle"
onclick="globalThis.web_api_confirm_submit(true)"
type="button"
>
{{ icon "check" }} {{ text "dialog:action.yes"
}}
</button>
<button
class="bold red camo"
onclick="globalThis.web_api_confirm_submit(false)"
type="button"
>
{{ icon "x" }} {{ text "dialog:action.no" }}
</button>
</div>
</div>
</form>
</div>
</dialog>
</body>
</html>

View file

@ -5,5 +5,5 @@
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="460" height="460" fill="#C9B1BC" />
<rect width="460" height="460" fill="#E793B9" />
</svg>

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 181 B

Before After
Before After

View file

@ -5,5 +5,5 @@
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="1500" height="350" fill="#C9B1BC" />
<rect width="1500" height="350" fill="#E793B9" />
</svg>

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 184 B

Before After
Before After

View file

@ -34,6 +34,9 @@ media_theme_pref();
(() => {
const self = reg_ns("atto");
// init
use("me", () => {});
// env
self.DEBOUNCE = [];
self.OBSERVERS = [];

View file

@ -0,0 +1,30 @@
(() => {
const self = reg_ns("me");
self.define("logout", async () => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch("/api/v1/auth/logout", {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "sucesss" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = "/";
}, 150);
}
});
});
})();

View file

@ -11,6 +11,7 @@ use axum::{
response::IntoResponse,
};
use axum_extra::extract::CookieJar;
use tetratto_shared::hash::hash;
/// `/api/v1/auth/register`
pub async fn register_request(
@ -20,7 +21,7 @@ pub async fn register_request(
Json(props): Json<AuthProps>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!((jar, data) <optional>);
let user = get_user_from_token!(jar, data);
if user.is_some() {
return (
@ -75,7 +76,7 @@ pub async fn login_request(
Json(props): Json<AuthProps>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!((jar, data) <optional>);
let user = get_user_from_token!(jar, data);
if user.is_some() {
return (None, Json(Error::AlreadyAuthenticated.into()));
@ -125,3 +126,50 @@ pub async fn login_request(
}),
)
}
/// `/api/v1/auth/logout`
pub async fn logout_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return (None, Json(Error::NotAllowed.into())),
};
// update tokens
let token = jar
.get("__Secure-atto-token")
.unwrap()
.to_string()
.replace("__Secure-atto-token=", "");
let mut new_tokens = user.tokens.clone();
new_tokens.remove(
new_tokens
.iter()
.position(|t| t.1 == hash(token.to_string()))
.unwrap(),
);
if let Err(e) = data.update_user_tokens(user.id, new_tokens).await {
return (None, Json(e.into()));
}
// ...
(
Some([(
"Set-Cookie",
format!(
"__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=0",
"refresh",
),
)]),
Json(ApiReturn {
ok: true,
message: "Goodbye!".to_string(),
payload: (),
}),
)
}

View file

@ -7,9 +7,11 @@ use serde::Deserialize;
pub fn routes() -> Router {
Router::new()
// auth
// global
.route("/auth/register", post(auth::register_request))
.route("/auth/login", post(auth::login_request))
.route("/auth/logout", post(auth::logout_request))
// profile
.route(
"/auth/profile/{id}/avatar",

View file

@ -1,27 +1,16 @@
use axum::response::IntoResponse;
/// `/public/favicon.svg`
pub async fn favicon_request() -> impl IntoResponse {
([("Content-Type", "image/svg+xml")], crate::assets::FAVICON)
macro_rules! serve_asset {
($fn_name:ident: $name:ident($type:literal)) => {
pub async fn $fn_name() -> impl IntoResponse {
([("Content-Type", $type)], crate::assets::$name)
}
};
}
/// `/css/style.css`
pub async fn style_css_request() -> impl IntoResponse {
([("Content-Type", "text/css")], crate::assets::STYLE_CSS)
}
serve_asset!(favicon_request: FAVICON("image/svg+xml"));
serve_asset!(style_css_request: STYLE_CSS("text/css"));
/// `/js/atto.js`
pub async fn atto_js_request() -> impl IntoResponse {
(
[("Content-Type", "text/javascript")],
crate::assets::ATTO_JS,
)
}
/// `/js/atto.js`
pub async fn loader_js_request() -> impl IntoResponse {
(
[("Content-Type", "text/javascript")],
crate::assets::LOADER_JS,
)
}
serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
serve_asset!(me_js_request: ME_JS("text/javascript"));

View file

@ -12,8 +12,9 @@ pub fn routes(config: &Config) -> Router {
Router::new()
// assets
.route("/css/style.css", get(assets::style_css_request))
.route("/js/atto.js", get(assets::atto_js_request))
.route("/js/loader.js", get(assets::loader_js_request))
.route("/js/atto.js", get(assets::atto_js_request))
.route("/js/me.js", get(assets::me_js_request))
.nest_service(
"/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),

View file

@ -8,7 +8,7 @@ use axum_extra::extract::CookieJar;
/// `/auth/login`
pub async fn login_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!((jar, data.0) <optional>);
let user = get_user_from_token!(jar, data.0);
if user.is_some() {
return Err(Redirect::to("/"));
@ -28,7 +28,7 @@ pub async fn register_request(
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!((jar, data.0) <optional>);
let user = get_user_from_token!(jar, data.0);
if user.is_some() {
return Err(Redirect::to("/"));

View file

@ -8,7 +8,7 @@ use axum_extra::extract::CookieJar;
/// `/`
pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!((jar, data.0) <optional>);
let user = get_user_from_token!(jar, data.0);
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &user);