diff --git a/Cargo.lock b/Cargo.lock
index 43705ca..6ee6475 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2569,6 +2569,7 @@ name = "tetratto-core"
 version = "0.1.0"
 dependencies = [
  "bb8-postgres",
+ "bitflags 2.9.0",
  "pathbufd",
  "rusqlite",
  "serde",
diff --git a/README.md b/README.md
index ee966b4..d7efa63 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,10 @@
 # 🐐 tetratto!
 
-This is the year of the personal website.
-
-Tetratto (`4 * 10^-18`) is a _super_ simple **dynamic** site server which takes in a conglomeration of HTML files (which are actually Jinja templates) and static files like CSS and JS, then serves them!
+Tetratto is your personal journal!
 
 ## Features
 
-- Templated HTML files (`html/` directory)
-- Markdown posts (`posts/` directory, served with `html/post.html` template)
-- Super simple SQLite database for authentication (and other stuff)
-
-## Usage
-
-Install Tetratto CLI:
-
-```bash
-cargo install tetratto
-```
-
-Clone the `./example` directory to get started.
-
-You can run a project by running `tetratto` in the directory. The entry file for CSS is assumed to be `public/css/style.css`. Note that your `index.html` file should _not_ include boilerplate stuff, and should instead just include a `{% block body %}` for beginning your content in the body. `{% block head %}` can be used to place data in the page head element. Templates should all extend the `_atto/root.html` template.
-
-### Config
-
-You can configure Tetratto by editing the project's `tetratto.toml` file.
-
-- `name`: the `{{ name }}` variable in templates (default: `Tetratto`)
-- `port`: the port the server is served on (default: `4118`)
-- `database`: the name of the file to store the SQLite database in (default: `./atto.db`)
+- Create new pages in your journal (essentially just posts)
+- Create new pages in your journal where people can post messages (essentially message boards that you control)
+- Follow other people and see their (public) journal entries
+    - Journal entries can either be public, unlisted (only accessible via link), and fully private (only accessible to moderators and the owner)
diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index 76603d3..27f8400 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -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
 }
diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index b9b4f00..cefd9ef 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -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"
diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs
index 5d9f90d..2c537c2 100644
--- a/crates/app/src/macros.rs
+++ b/crates/app/src/macros.rs
@@ -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]
diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index f69a77b..ef37f2c 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -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;
diff --git a/crates/app/src/public/html/auth/login.html b/crates/app/src/public/html/auth/login.html
index 6115710..ed178a9 100644
--- a/crates/app/src/public/html/auth/login.html
+++ b/crates/app/src/public/html/auth/login.html
@@ -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
diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html
index 3acf16b..ed6d2d6 100644
--- a/crates/app/src/public/html/macros.html
+++ b/crates/app/src/public/html/macros.html
@@ -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>
diff --git a/crates/app/src/public/html/misc/index.html b/crates/app/src/public/html/misc/index.html
index dd53adf..e89bf38 100644
--- a/crates/app/src/public/html/misc/index.html
+++ b/crates/app/src/public/html/misc/index.html
@@ -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 %}
diff --git a/crates/app/src/public/html/root.html b/crates/app/src/public/html/root.html
index 7481cea..c6183d1 100644
--- a/crates/app/src/public/html/root.html
+++ b/crates/app/src/public/html/root.html
@@ -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>
diff --git a/crates/app/src/public/images/default-avatar.svg b/crates/app/src/public/images/default-avatar.svg
index a4cf241..00fa7ab 100644
--- a/crates/app/src/public/images/default-avatar.svg
+++ b/crates/app/src/public/images/default-avatar.svg
@@ -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>
diff --git a/crates/app/src/public/images/default-banner.svg b/crates/app/src/public/images/default-banner.svg
index a8edad2..05ae323 100644
--- a/crates/app/src/public/images/default-banner.svg
+++ b/crates/app/src/public/images/default-banner.svg
@@ -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>
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 1a89521..4499a88 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -34,6 +34,9 @@ media_theme_pref();
 (() => {
     const self = reg_ns("atto");
 
+    // init
+    use("me", () => {});
+
     // env
     self.DEBOUNCE = [];
     self.OBSERVERS = [];
diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js
new file mode 100644
index 0000000..b617364
--- /dev/null
+++ b/crates/app/src/public/js/me.js
@@ -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);
+                }
+            });
+    });
+})();
diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs
index 7fee2a1..38d4752 100644
--- a/crates/app/src/routes/api/v1/auth/mod.rs
+++ b/crates/app/src/routes/api/v1/auth/mod.rs
@@ -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: (),
+        }),
+    )
+}
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index c0d0906..b927576 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -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",
diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs
index 5e7144c..df251d2 100644
--- a/crates/app/src/routes/assets.rs
+++ b/crates/app/src/routes/assets.rs
@@ -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"));
diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs
index 4ec6673..bf73c9d 100644
--- a/crates/app/src/routes/mod.rs
+++ b/crates/app/src/routes/mod.rs
@@ -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)),
diff --git a/crates/app/src/routes/pages/auth.rs b/crates/app/src/routes/pages/auth.rs
index d162722..85ab3c6 100644
--- a/crates/app/src/routes/pages/auth.rs
+++ b/crates/app/src/routes/pages/auth.rs
@@ -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("/"));
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index 53c71f9..acf8f82 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -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);
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 22f09b9..795efac 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -20,3 +20,4 @@ rusqlite = { version = "0.34.0", optional = true }
 
 tokio-postgres = { version = "0.7.13", optional = true }
 bb8-postgres = { version = "0.9.0", optional = true }
+bitflags = "2.9.0"
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index 857edf9..445ab2f 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -1,4 +1,5 @@
 use super::*;
+use crate::model::permissions::FinePermission;
 use crate::model::{Error, Result};
 use crate::{execute, get, query_row};
 
@@ -18,12 +19,13 @@ impl DataManager {
     ) -> User {
         User {
             id: get!(x->0(u64)) as usize,
-            created: get!(x->1(u64)) as usize as usize,
+            created: get!(x->1(u64)) as usize,
             username: get!(x->2(String)),
             password: get!(x->3(String)),
             salt: get!(x->4(String)),
             settings: serde_json::from_str(&get!(x->5(String)).to_string()).unwrap(),
             tokens: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(),
+            permissions: FinePermission::from_bits(get!(x->7(u32))).unwrap(),
         }
     }
 
@@ -124,15 +126,16 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7)",
+            "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
             &[
-                &data.id.to_string(),
-                &data.created.to_string(),
-                &data.username,
-                &data.password,
-                &data.salt,
-                &serde_json::to_string(&data.settings).unwrap(),
-                &serde_json::to_string(&data.tokens).unwrap(),
+                &data.id.to_string().as_str(),
+                &data.created.to_string().as_str(),
+                &data.username.as_str(),
+                &data.password.as_str(),
+                &data.salt.as_str(),
+                &serde_json::to_string(&data.settings).unwrap().as_str(),
+                &serde_json::to_string(&data.tokens).unwrap().as_str(),
+                &(FinePermission::DEFAULT.bits()).to_string().as_str()
             ]
         );
 
diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql
index 869c9f6..2b7420e 100644
--- a/crates/core/src/database/drivers/sql/create_users.sql
+++ b/crates/core/src/database/drivers/sql/create_users.sql
@@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS users (
     password TEXT NOT NULL,
     salt TEXT NOT NULL,
     settings TEXT NOT NULL,
-    tokens TEXT NOT NULL
+    tokens TEXT NOT NULL,
+    permissions INTEGER NOT NULL
 )
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index 6faaafc..0bf3df8 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -5,6 +5,8 @@ use tetratto_shared::{
     unix_epoch_timestamp,
 };
 
+use super::permissions::FinePermission;
+
 /// `(ip, token, creation timestamp)`
 pub type Token = (String, String, usize);
 
@@ -17,6 +19,7 @@ pub struct User {
     pub salt: String,
     pub settings: UserSettings,
     pub tokens: Vec<Token>,
+    pub permissions: FinePermission,
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -45,6 +48,7 @@ impl User {
             salt,
             settings: UserSettings::default(),
             tokens: Vec::new(),
+            permissions: FinePermission::DEFAULT,
         }
     }
 
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index 44d2392..b3af128 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -1,4 +1,5 @@
 pub mod auth;
+pub mod permissions;
 use serde::{Deserialize, Serialize};
 
 #[derive(Serialize, Deserialize)]
@@ -18,6 +19,7 @@ pub enum Error {
     RegistrationDisabled,
     DatabaseError(String),
     IncorrectPassword,
+    NotAllowed,
     AlreadyAuthenticated,
     DataTooLong(String),
     DataTooShort(String),
@@ -27,14 +29,15 @@ pub enum Error {
 impl ToString for Error {
     fn to_string(&self) -> String {
         match self {
-            Error::DatabaseConnection(msg) => msg.to_owned(),
-            Error::DatabaseError(msg) => format!("Database error: {msg}"),
-            Error::UserNotFound => "Unable to find user with given parameters".to_string(),
-            Error::RegistrationDisabled => "Registration is disabled".to_string(),
-            Error::IncorrectPassword => "The given password is invalid".to_string(),
-            Error::AlreadyAuthenticated => "Already authenticated".to_string(),
-            Error::DataTooLong(name) => format!("Given {name} is too long!"),
-            Error::DataTooShort(name) => format!("Given {name} is too short!"),
+            Self::DatabaseConnection(msg) => msg.to_owned(),
+            Self::DatabaseError(msg) => format!("Database error: {msg}"),
+            Self::UserNotFound => "Unable to find user with given parameters".to_string(),
+            Self::RegistrationDisabled => "Registration is disabled".to_string(),
+            Self::IncorrectPassword => "The given password is invalid".to_string(),
+            Self::NotAllowed => "You are not allowed to do this".to_string(),
+            Self::AlreadyAuthenticated => "Already authenticated".to_string(),
+            Self::DataTooLong(name) => format!("Given {name} is too long!"),
+            Self::DataTooShort(name) => format!("Given {name} is too short!"),
             _ => format!("An unknown error as occurred: ({:?})", self),
         }
     }
diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs
new file mode 100644
index 0000000..f91153a
--- /dev/null
+++ b/crates/core/src/model/permissions.rs
@@ -0,0 +1,123 @@
+use bitflags::bitflags;
+use serde::{
+    Deserialize, Deserializer, Serialize,
+    de::{Error as DeError, Visitor},
+};
+
+bitflags! {
+    /// Fine-grained permissions built using bitwise operations.
+    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+    pub struct FinePermission: u32 {
+        const DEFAULT = 1 << 0;
+        const ADMINISTRATOR = 1 << 1;
+        const MANAGE_JOURNAL_PAGES = 1 << 2;
+        const MANAGE_JOURNAL_ENTRIES = 1 << 3;
+        const MANAGE_JOURNAL_ENTRY_COMMENTS = 1 << 4;
+        const MANAGE_USERS = 1 << 5;
+        const MANAGE_BANS = 1 << 6; // includes managing IP bans
+        const MANAGE_WARNINGS = 1 << 7;
+        const MANAGE_NOTIFICATIONS = 1 << 8;
+        const VIEW_REPORTS = 1 << 9;
+        const VIEW_AUDIT_LOG = 1 << 10;
+
+        const _ = !0;
+    }
+}
+
+impl Serialize for FinePermission {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_u32(self.bits())
+    }
+}
+
+struct FinePermissionVisitor;
+impl<'de> Visitor<'de> for FinePermissionVisitor {
+    type Value = FinePermission;
+
+    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+        formatter.write_str("u32")
+    }
+
+    fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E>
+    where
+        E: DeError,
+    {
+        if let Some(permission) = FinePermission::from_bits(value) {
+            Ok(permission)
+        } else {
+            Ok(FinePermission::from_bits_retain(value))
+        }
+    }
+
+    fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E>
+    where
+        E: DeError,
+    {
+        if let Some(permission) = FinePermission::from_bits(value as u32) {
+            Ok(permission)
+        } else {
+            Ok(FinePermission::from_bits_retain(value as u32))
+        }
+    }
+
+    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
+    where
+        E: DeError,
+    {
+        if let Some(permission) = FinePermission::from_bits(value as u32) {
+            Ok(permission)
+        } else {
+            Ok(FinePermission::from_bits_retain(value as u32))
+        }
+    }
+}
+
+impl<'de> Deserialize<'de> for FinePermission {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        deserializer.deserialize_any(FinePermissionVisitor)
+    }
+}
+
+impl FinePermission {
+    /// Join two [`FinePermission`]s into a single `u32`.
+    pub fn join(lhs: FinePermission, rhs: FinePermission) -> FinePermission {
+        lhs | rhs
+    }
+
+    /// Check if the given `input` contains the given [`FinePermission`].
+    pub fn check(self, permission: FinePermission) -> bool {
+        if (self & FinePermission::ADMINISTRATOR) == FinePermission::ADMINISTRATOR {
+            // has administrator permission, meaning everything else is automatically true
+            return true;
+        }
+
+        (self & permission) == permission
+    }
+
+    /// Check if thhe given [`FinePermission`] is qualifies as "Helper" status.
+    pub fn check_helper(self) -> bool {
+        self.check(FinePermission::MANAGE_JOURNAL_ENTRIES)
+            && self.check(FinePermission::MANAGE_JOURNAL_PAGES)
+            && self.check(FinePermission::MANAGE_JOURNAL_ENTRY_COMMENTS)
+            && self.check(FinePermission::MANAGE_WARNINGS)
+            && self.check(FinePermission::VIEW_REPORTS)
+            && self.check(FinePermission::VIEW_AUDIT_LOG)
+    }
+
+    /// Check if thhe given [`FinePermission`] is qualifies as "Manager" status.
+    pub fn check_manager(self) -> bool {
+        self.check_helper() && self.check(FinePermission::ADMINISTRATOR)
+    }
+}
+
+impl Default for FinePermission {
+    fn default() -> Self {
+        Self::DEFAULT
+    }
+}
diff --git a/crates/l10n/LICENSE b/crates/l10n/LICENSE
new file mode 120000
index 0000000..30cff74
--- /dev/null
+++ b/crates/l10n/LICENSE
@@ -0,0 +1 @@
+../../LICENSE
\ No newline at end of file
diff --git a/crates/shared/LICENSE b/crates/shared/LICENSE
new file mode 120000
index 0000000..30cff74
--- /dev/null
+++ b/crates/shared/LICENSE
@@ -0,0 +1 @@
+../../LICENSE
\ No newline at end of file
diff --git a/tetratto.toml b/tetratto.toml
deleted file mode 100644
index 0aa07cd..0000000
--- a/tetratto.toml
+++ /dev/null
@@ -1,16 +0,0 @@
-name = "Tetratto"
-description = "🐐 tetratto!"
-color = "#c9b1bc"
-port = 4118
-
-[security]
-registration_enabled = true
-admin_user = "admin"
-real_ip_header = "CF-Connecting-IP"
-
-[dirs]
-templates = "html"
-assets = "public"
-
-[database]
-name = "atto.db"