From bee66a8d6c391360290a0365c8216014a7d0602a Mon Sep 17 00:00:00 2001
From: Andrew Rioux
Date: Sun, 26 Jan 2025 17:33:26 -0500
Subject: [PATCH] feat: added basic user management
---
Cargo.lock | 1 +
flake.nix | 4 +-
packages.nix | 2 +
...10e8537b03cc9ff00c248ccc7c34a4a8366c.json} | 12 +-
...2d5e15da15f805064f1e969a1d6d9516294b6.json | 12 +
sparse-server/Cargo.toml | 6 +-
sparse-server/src/app.rs | 94 ++---
sparse-server/src/cli/user.rs | 44 +--
sparse-server/src/db/user.rs | 41 ++-
sparse-server/src/error.rs | 37 ++
sparse-server/src/lib.rs | 2 +
sparse-server/src/main.rs | 2 +
sparse-server/src/users.rs | 340 ++++++++++++++++--
sparse-server/src/webserver.rs | 1 +
sparse-server/style/_main.scss | 3 +
sparse-server/style/_users.scss | 24 ++
sparse-server/style/main.scss | 82 ++++-
17 files changed, 587 insertions(+), 120 deletions(-)
rename sparse-server/.sqlx/{query-7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09.json => query-ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c.json} (50%)
create mode 100644 sparse-server/.sqlx/query-fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6.json
create mode 100644 sparse-server/src/error.rs
create mode 100644 sparse-server/style/_main.scss
create mode 100644 sparse-server/style/_users.scss
diff --git a/Cargo.lock b/Cargo.lock
index 052482a..bc7cbed 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -369,6 +369,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
+ "serde",
"wasm-bindgen",
"windows-targets 0.52.6",
]
diff --git a/flake.nix b/flake.nix
index 7fc2d45..c1b79cf 100644
--- a/flake.nix
+++ b/flake.nix
@@ -70,6 +70,7 @@
wasm-bindgen-cli
dart-sass
binaryen
+ sqlx-cli
];
freebsd = buildTools.linux
++ [ pkgsCross.x86_64-freebsd.buildPackages.clang ];
@@ -89,9 +90,6 @@
# Cargo lint tools
taplo
cargo-deny
-
- # Web server tools
- sqlx-cli
];
craneLib = (crane.mkLib pkgs).overrideToolchain (p:
diff --git a/packages.nix b/packages.nix
index ce8ec57..0a5fd90 100644
--- a/packages.nix
+++ b/packages.nix
@@ -92,6 +92,8 @@ let
(craneLib.fileset.commonCargoSources ./sparse-server)
./sparse-server/style
./sparse-server/public
+ ./sparse-server/.sqlx
+ ./sparse-server/migrations
];
};
diff --git a/sparse-server/.sqlx/query-7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09.json b/sparse-server/.sqlx/query-ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c.json
similarity index 50%
rename from sparse-server/.sqlx/query-7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09.json
rename to sparse-server/.sqlx/query-ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c.json
index 5a9dfe8..03cd149 100644
--- a/sparse-server/.sqlx/query-7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09.json
+++ b/sparse-server/.sqlx/query-ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c.json
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
- "query": "SELECT user_id, user_name FROM users",
+ "query": "SELECT user_id, user_name, (SELECT MAX(expires) FROM sessions s WHERE s.user_id = u.user_id) as last_active FROM users u",
"describe": {
"columns": [
{
@@ -12,6 +12,11 @@
"name": "user_name",
"ordinal": 1,
"type_info": "Text"
+ },
+ {
+ "name": "last_active",
+ "ordinal": 2,
+ "type_info": "Integer"
}
],
"parameters": {
@@ -19,8 +24,9 @@
},
"nullable": [
false,
- false
+ false,
+ true
]
},
- "hash": "7e9ab05c719af53d1feb7f03e9090892f870664687240210e9f4336692599b09"
+ "hash": "ed123391fb7afe255dc30bb1006410e8537b03cc9ff00c248ccc7c34a4a8366c"
}
diff --git a/sparse-server/.sqlx/query-fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6.json b/sparse-server/.sqlx/query-fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6.json
new file mode 100644
index 0000000..a4e3276
--- /dev/null
+++ b/sparse-server/.sqlx/query-fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "DELETE FROM users WHERE user_id = ?",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "fe857854bbacf9e8fc44ef0dffc2d5e15da15f805064f1e969a1d6d9516294b6"
+}
diff --git a/sparse-server/Cargo.toml b/sparse-server/Cargo.toml
index 5f274fb..d4bf27d 100644
--- a/sparse-server/Cargo.toml
+++ b/sparse-server/Cargo.toml
@@ -28,10 +28,10 @@ tokio-stream = { version = "0.1", optional = true }
futures-util = { version = "0.3", optional = true }
tracing = { version = "0.1", optional = true }
web-sys = { version = "0.3", features = ["WebSocket"] }
-leptos-use = { version = "0.15", default-features = false, features = ["use_websocket"] }
+leptos-use = { version = "0.15", default-features = false, features = ["use_websocket", "use_interval"] }
codee = "0.2"
sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite"], optional = true }
-chrono = "0.4"
+chrono = { version = "0.4", features = ["serde"] }
rpassword = { version = "7.3", optional = true }
pbkdf2 = { version = "0.12", features = ["simple", "sha2"], optional = true }
sha2 = { version = "0.10", optional = true }
@@ -39,7 +39,7 @@ hex = { version = "0.4", optional = true }
serde = "1.0"
[features]
-hydrate = ["leptos/hydrate"]
+hydrate = ["leptos/hydrate", "chrono/wasmbind"]
ssr = [
"dep:axum",
"dep:tokio",
diff --git a/sparse-server/src/app.rs b/sparse-server/src/app.rs
index d89be7d..e276e92 100644
--- a/sparse-server/src/app.rs
+++ b/sparse-server/src/app.rs
@@ -1,7 +1,7 @@
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_router::{
- components::{Route, Router, Routes},
+ components::{A, Route, Router, Routes},
StaticSegment,
};
@@ -50,12 +50,18 @@ pub fn App() -> impl IntoView {
// content for this welcome page
-
-
-
-
-
-
+
+
+
+
+
+
}
}
@@ -104,41 +110,43 @@ fn HomePage() -> impl IntoView {
};
view! {
- "Welcome to Leptos!"
-
- "Loading..."
}
- >
- "Loaded time:"
- {move || {
- loaded_time.get()
- .map(|time| match time {
- Ok(t) => view! { {format!("{}", t)}
},
- Err(_) => view! { {"Error!".to_string()}
}
- })
- }}
- "Requested time:"
-
- {move || pending.get().then_some("Loading...")}
- {move || match requested_time.get() {
- Some(t) => {
- leptos::logging::log!("updating time display");
- view! { {t}
}
- },
- None => view! { {"N/A".to_string()}
}
- }}
-
- "Messages"
-
-
- {move || messages
- .get()
- .iter()
- .map(|message| view! { {message.clone()}
})
- .collect::>()}
+
+ "Welcome to Leptos!"
+
+ "Loading..." }
+ >
+ "Loaded time:"
+ {move || {
+ loaded_time.get()
+ .map(|time| match time {
+ Ok(t) => view! { {format!("{}", t)}
},
+ Err(_) => view! { {"Error!".to_string()}
}
+ })
+ }}
+ "Requested time:"
+
+ {move || pending.get().then_some("Loading...")}
+ {move || match requested_time.get() {
+ Some(t) => {
+ leptos::logging::log!("updating time display");
+ view! { {t}
}
+ },
+ None => view! { {"N/A".to_string()}
}
+ }}
+
+ "Messages"
+
+
+ {move || messages
+ .get()
+ .iter()
+ .map(|message| view! { {message.clone()}
})
+ .collect::>()}
+
}
}
diff --git a/sparse-server/src/cli/user.rs b/sparse-server/src/cli/user.rs
index 8e033cb..16def65 100644
--- a/sparse-server/src/cli/user.rs
+++ b/sparse-server/src/cli/user.rs
@@ -24,30 +24,23 @@ async fn list_users(db: SqlitePool) -> anyhow::Result {
Ok(ExitCode::SUCCESS)
}
-async fn create_user(db: SqlitePool, name: String) -> anyhow::Result {
- let mut tx = db.begin().await?;
+fn get_password() -> anyhow::Result {
+ let password1 = rpassword::prompt_password("Enter new password: ")?;
+ let password2 = rpassword::prompt_password("Enter password again: ")?;
- let previous_user_check = sqlx::query_scalar!(
- "SELECT COUNT(*) FROM users WHERE user_name = ?",
- name
- )
- .fetch_one(&mut *tx)
- .await?;
-
- if previous_user_check > 0 {
- eprintln!("Error! User already exists!");
- return Ok(ExitCode::FAILURE);
+ if password1 != password2 {
+ Err(anyhow::anyhow!("Passwords do not match!"))?
}
- let new_id = query!(
- r#"INSERT INTO users (user_name, password_salt, password_hash) VALUES (?, "", "")"#,
- name
- )
- .execute(&mut *tx)
- .await?
- .last_insert_rowid();
+ Ok(password1)
+}
- reset_password(&mut *tx, new_id as i16).await?;
+async fn create_user(db: SqlitePool, name: String) -> anyhow::Result {
+ let password = get_password()?;
+
+ let mut tx = db.begin().await?;
+
+ crate::db::user::create_user(&mut tx, name, password).await?;
tx.commit().await?;
@@ -58,16 +51,11 @@ async fn create_user(db: SqlitePool, name: String) -> anyhow::Result {
async fn reset_password<'a, E>(db: E, id: i16) -> anyhow::Result
where
- E: sqlx::Executor<'a, Database = sqlx::Sqlite>
+ E: sqlx::SqliteExecutor<'a>
{
- let password1 = rpassword::prompt_password("Enter new password: ")?;
- let password2 = rpassword::prompt_password("Enter password again: ")?;
+ let password = get_password()?;
- if password1 != password2 {
- Err(anyhow::anyhow!("Passwords do not match!"))?
- }
-
- crate::db::user::reset_password(db, id, password1).await?;
+ crate::db::user::reset_password(db, id, password).await?;
println!("Password set successfully!");
diff --git a/sparse-server/src/db/user.rs b/sparse-server/src/db/user.rs
index 44abd09..d3d5d62 100644
--- a/sparse-server/src/db/user.rs
+++ b/sparse-server/src/db/user.rs
@@ -1,12 +1,13 @@
-use sqlx::{sqlite::SqlitePool, Database};
use pbkdf2::{pbkdf2_hmac_array, password_hash::{rand_core::OsRng, SaltString}};
use sha2::Sha256;
+use crate::error::Error;
+
const PASSWORD_ITERATIONS: u32 = 100_000;
-pub async fn reset_password<'a, E>(pool: E, id: i16, password: String) -> anyhow::Result<()>
+pub async fn reset_password<'a, E>(pool: E, id: i16, password: String) -> Result<(), crate::error::Error>
where
- E: sqlx::Executor<'a, Database = sqlx::Sqlite>
+ E: sqlx::SqliteExecutor<'a>
{
let salt = SaltString::generate(&mut OsRng);
@@ -30,3 +31,37 @@ where
Ok(())
}
+
+pub async fn create_user<'a, E>(acq: E, name: String, password: String) -> Result<(), crate::error::Error>
+where
+ E: sqlx::Acquire<'a, Database = sqlx::Sqlite>
+{
+ let mut tx = acq.begin().await?;
+
+ let previous_user_check = sqlx::query_scalar!(
+ "SELECT COUNT(*) FROM users WHERE user_name = ?",
+ name
+ )
+ .fetch_one(&mut *tx)
+ .await?;
+
+ if previous_user_check > 0 {
+ return Err(Error::UserCreate("User already exists".to_string()));
+ }
+
+ tracing::info!("Creating new user {}", name);
+
+ let new_id = sqlx::query!(
+ r#"INSERT INTO users (user_name, password_salt, password_hash) VALUES (?, "", "")"#,
+ name
+ )
+ .execute(&mut *tx)
+ .await?
+ .last_insert_rowid();
+
+ reset_password(&mut *tx, new_id as i16, password).await?;
+
+ tx.commit().await?;
+
+ Ok(())
+}
diff --git a/sparse-server/src/error.rs b/sparse-server/src/error.rs
new file mode 100644
index 0000000..9e5d010
--- /dev/null
+++ b/sparse-server/src/error.rs
@@ -0,0 +1,37 @@
+#[derive(Debug)]
+pub enum Error {
+ UserCreate(String),
+ #[cfg(feature = "ssr")]
+ Sqlx(sqlx::Error),
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Error::UserCreate(err) => {
+ write!(f, "user create error: {err}")
+ }
+ #[cfg(feature = "ssr")]
+ Error::Sqlx(err) => {
+ write!(f, "sqlx error: {err:?}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ #[cfg(feature = "ssr")]
+ Error::Sqlx(err) => Some(err),
+ _ => None,
+ }
+ }
+}
+
+#[cfg(feature = "ssr")]
+impl From for Error {
+ fn from(err: sqlx::Error) -> Self {
+ Self::Sqlx(err)
+ }
+}
diff --git a/sparse-server/src/lib.rs b/sparse-server/src/lib.rs
index d5aa96b..422c291 100644
--- a/sparse-server/src/lib.rs
+++ b/sparse-server/src/lib.rs
@@ -2,6 +2,8 @@ pub mod app;
pub mod users;
+pub mod error;
+
pub mod db;
#[cfg(feature = "hydrate")]
diff --git a/sparse-server/src/main.rs b/sparse-server/src/main.rs
index 6b8ad73..8bcd358 100644
--- a/sparse-server/src/main.rs
+++ b/sparse-server/src/main.rs
@@ -17,6 +17,8 @@ mod webserver;
#[cfg(feature = "ssr")]
pub mod users;
+pub mod error;
+
pub mod db;
#[cfg(feature = "ssr")]
diff --git a/sparse-server/src/users.rs b/sparse-server/src/users.rs
index a198954..5606430 100644
--- a/sparse-server/src/users.rs
+++ b/sparse-server/src/users.rs
@@ -1,3 +1,4 @@
+use chrono::{DateTime, offset::Utc};
use leptos::prelude::*;
use serde::{Serialize, Deserialize};
#[cfg(feature = "ssr")]
@@ -5,63 +6,334 @@ use {
sqlx::SqlitePool
};
+fn format_delta(time: chrono::TimeDelta) -> String {
+ let seconds = time.num_seconds();
+
+ match seconds {
+ 0..=59 => format!("{} second{} ago", seconds, if seconds == 1 {""} else {"s"}),
+ 60..=3599 => {
+ let minutes = seconds / 60;
+ format!("{} minute{} ago", minutes, if minutes == 1 {""} else {"s"})
+ }
+ 3600..=86399 => {
+ let hours = seconds / 3600;
+ format!("{} hours{} ago", hours, if hours == 1 {""} else {"s"})
+ }
+ _ => {
+ let days = seconds / 86400;
+ format!("{} day{} ago", days, if days == 1 {""} else {"s"})
+ }
+ }
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct DbUser {
+ user_id: i64,
+ user_name: String,
+ last_active: Option
+}
+
#[derive(Clone, Serialize, Deserialize)]
pub struct PubUser {
user_id: i64,
- user_name: String
+ user_name: String,
+ last_active: Option>
+}
+
+#[server]
+async fn delete_user(user_id: i64) -> Result<(), ServerFnError> {
+ let pool = expect_context::();
+
+ sqlx::query!(
+ "DELETE FROM users WHERE user_id = ?",
+ user_id
+ )
+ .execute(&pool)
+ .await?;
+
+ Ok(())
+}
+
+#[server]
+async fn reset_password(user_id: i64, password: String) -> Result<(), ServerFnError> {
+ let pool = expect_context::();
+
+ crate::db::user::reset_password(&pool, user_id as i16, password).await?;
+
+ Ok(())
+}
+
+#[component]
+pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl IntoView {
+ use leptos_use::{use_interval, UseIntervalReturn};
+
+ let UseIntervalReturn { counter, .. } = use_interval(1000);
+ let (time_ago, set_time_ago) = signal(user.last_active.map(|active| format_delta(Utc::now() - active)));
+
+ Effect::watch(
+ move || counter.get(),
+ move |_, _, _| {
+ set_time_ago(user.last_active.map(|active| format_delta(Utc::now() - active)));
+ },
+ false
+ );
+
+ let dialog_ref = NodeRef::::new();
+ let new_password = RwSignal::new("".to_owned());
+
+ let error_dialog_ref = NodeRef::::new();
+ let (error_msg, set_error_msg) = signal(None::);
+ let clear_error = move |_| {
+ set_error_msg(None);
+ };
+
+ let handle_delete = Action::new(move |&id: &i64| async move {
+ match delete_user(id).await {
+ Ok(()) => {
+ refresh_user_list.dispatch(());
+ }
+ Err(e) => {
+ set_error_msg(Some(e.to_string()));
+ }
+ }
+ });
+
+ let handle_reset = Action::new(move |info: &(i64, String)| {
+ let (id, password) = info.clone();
+
+ async move {
+ match reset_password(id, password).await {
+ Ok(()) => {}
+ Err(e) => {
+ set_error_msg(Some(e.to_string()));
+ }
+ }
+ }
+ });
+
+ view! {
+
+
+
+ {user.user_id}
+ ": "
+ {user.user_name}
+ {move || time_ago.get().map(|active| view! {
+
+ "(last activity: "
+ {active}
+ ")"
+
+ })}
+
+
+
+
+
+
+ }
}
#[server]
async fn list_users() -> Result, ServerFnError> {
- use leptos::server_fn::error::NoCustomError;
+ use futures::stream::StreamExt;
let pool = expect_context::();
let users = sqlx::query_as!(
- PubUser,
- "SELECT user_id, user_name FROM users"
+ DbUser,
+ "SELECT user_id, user_name, (SELECT MAX(expires) FROM sessions s WHERE s.user_id = u.user_id) as last_active FROM users u"
)
- .fetch_all(&pool)
- .await?;
+ .fetch(&pool)
+ .map(|user| user.map(|u| PubUser {
+ user_id: u.user_id,
+ user_name: u.user_name,
+ last_active: u.last_active.map(|ts| DateTime::from_timestamp(ts, 0)).flatten()
+ }))
+ .collect::>>()
+ .await;
- Ok(users)
+ let users: Result, _> = users.into_iter().collect();
+
+ Ok(users?)
+}
+
+#[server]
+async fn add_user(name: String, password: String) -> Result<(), ServerFnError> {
+ let pool = expect_context::();
+
+ crate::db::user::create_user(&pool, name, password).await?;
+
+ Ok(())
}
#[component]
pub fn UserView() -> impl IntoView {
let user_list = Resource::new(|| (), |_| async move { list_users().await });
- view! {
- "User list"
- "Loading..." }
- >
- "Errors loading users!"
-
- {move || errors.get()
- .into_iter()
- .map(|(_, e)| view! { - {e.to_string()}
})
- .collect::>()}
-
- }
- >
- {move || Suspend::new(async move {
- let users_res = user_list.await;
+ let modal_ref = NodeRef::::new();
- users_res.map(|users| view! {
+ let new_user_name = RwSignal::new("".to_owned());
+ let new_user_pass = RwSignal::new("".to_owned());
+ let (user_add_error, set_user_add_error) = signal(None::);
+ let add_user_action = Action::new(move |_: &()| async move {
+ if let Some(modal) = modal_ref.get() {
+ let _ = modal.close();
+ }
+
+ match add_user(new_user_name.get(), new_user_pass.get()).await {
+ Ok(()) => {
+ user_list.refetch();
+ }
+ Err(e) => {
+ set_user_add_error(Some(format!("Error adding user! {e:?}")));
+ }
+ };
+
+ new_user_name.set("".to_owned());
+ new_user_pass.set("".to_owned());
+ });
+
+ let refresh_user_list = Action::new(move |_: &()| async move {
+ user_list.refetch();
+ });
+
+ view! {
+
+
+
+ {move || user_add_error.get().map(|err| view! {
+
+ "Error adding user! "
+ {err}
+
+ })}
+
+ "User list"
+ "Loading..." }
+ >
+ "Errors loading users!"
- {users
+ {move || errors.get()
.into_iter()
- .map(|user| view! {
- - {user.user_id}": "{user.user_name}
- })
+ .map(|(_, e)| view! { - {e.to_string()}
})
.collect::>()}
- })
- })}
-
-
+ }
+ >
+ {move || Suspend::new(async move {
+ let users_res = user_list.await;
+
+ users_res.map(|users| view! {
+
+ {users
+ .into_iter()
+ .map(|user| view! {
+
+ })
+ .collect::>()}
+
+ })
+ })}
+
+
+
}
}
diff --git a/sparse-server/src/webserver.rs b/sparse-server/src/webserver.rs
index adea3d4..242df5f 100644
--- a/sparse-server/src/webserver.rs
+++ b/sparse-server/src/webserver.rs
@@ -9,6 +9,7 @@ use sparse_server::app::*;
pub async fn websocket(ws: axum::extract::ws::WebSocketUpgrade) -> axum::response::Response {
+ tracing::info!("Handling websocket request to /ws");
ws.on_upgrade(handle_websocket)
}
diff --git a/sparse-server/style/_main.scss b/sparse-server/style/_main.scss
new file mode 100644
index 0000000..9f02515
--- /dev/null
+++ b/sparse-server/style/_main.scss
@@ -0,0 +1,3 @@
+main.main {
+
+}
diff --git a/sparse-server/style/_users.scss b/sparse-server/style/_users.scss
new file mode 100644
index 0000000..5ba6433
--- /dev/null
+++ b/sparse-server/style/_users.scss
@@ -0,0 +1,24 @@
+main.users {
+ dialog::backdrop {
+ background-color: #0008;
+ }
+
+ form fieldset {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+
+ * {
+ display: inline-block;
+ margin: 10px;
+ }
+ }
+
+ ul {
+ list-style-type: none;
+ }
+
+ li button {
+ margin-left: 10px;
+ margin-right: 0px;
+ }
+}
diff --git a/sparse-server/style/main.scss b/sparse-server/style/main.scss
index e4538e1..967db27 100644
--- a/sparse-server/style/main.scss
+++ b/sparse-server/style/main.scss
@@ -1,4 +1,80 @@
-body {
+@use '_users';
+@use '_main';
+
+html, body {
font-family: sans-serif;
- text-align: center;
-}
\ No newline at end of file
+ background-color: #201f30;
+
+ color: white;
+
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+}
+
+body {
+ display: grid;
+ grid-template-columns: 350px 1fr;
+ grid-template-rows: 79px 1fr;
+ grid-template-areas:
+ "nav nav"
+ "beacons main";
+}
+
+nav {
+ background-color: #11111c;
+ grid-area: nav;
+ border-bottom: 1px solid #2e2e59;
+
+ h1 {
+ color: white;
+ text-decoration: none;
+ display: inline-block;
+ padding: 15px;
+ margin: 10px 5px;
+ }
+
+ a, a:visited {
+ color: white;
+ text-decoration: none;
+ display: inline-block;
+ padding: 10px;
+ margin: 10px 5px;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ }
+}
+
+aside.beacons {
+ grid-area: beacons;
+ background-color: #11111c;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+main {
+ grid-area: main;
+
+ padding: 20px;
+
+ overflow: auto;
+}
+
+input[type="submit"],
+input[type="button"],
+button {
+ background-color: #fff;
+ border: 1px solid transparent;
+ cursor: pointer;
+ margin: 5px;
+ padding: 2px 4px;
+ border-radius: 2px;
+ box-shadow: #fff7 0 1px 0 inset;
+
+ &:hover {
+ background-color: #ccc;
+ }
+}