Create web skeleton, including Tailwind for CSS and hot reloading of CSS
and templates. Tihs also adds an ADR for using Tailwind. Add a pile of dependencies setup orm, add admin tool admin tool does random pass if none provided add tons of css stuff finish up web skeleton
This commit is contained in:
parent
77d4ebb371
commit
3acafda0d3
43 changed files with 6617 additions and 118 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
@ -0,0 +1 @@
|
||||||
|
DATABASE_URL=sqlite:./pique.db?mode=rwc
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,3 +1,5 @@
|
||||||
target/
|
target/
|
||||||
|
node_modules/
|
||||||
*.db
|
*.db
|
||||||
*.xml
|
*.xml
|
||||||
|
.env
|
||||||
|
|
1422
Cargo.lock
generated
1422
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
24
Cargo.toml
24
Cargo.toml
|
@ -1,9 +1,31 @@
|
||||||
workspace = { members = ["_experiments/2024-03-02-database-benchmark"] }
|
|
||||||
[package]
|
[package]
|
||||||
name = "pique"
|
name = "pique"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
default-run = "pique"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = "1.0.81"
|
||||||
|
argon2 = { version = "0.5.3", features = ["rand", "std"] }
|
||||||
|
async-trait = "0.1.78"
|
||||||
|
axum = "0.7.4"
|
||||||
|
axum-htmx = { version = "0.5.0", features = ["guards", "serde"] }
|
||||||
|
axum-login = "0.14.0"
|
||||||
|
clap = { version = "4.5.3", features = ["derive", "env"] }
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
env_logger = "0.11.3"
|
||||||
|
minijinja = { version = "1.0.14", features = ["loader"] }
|
||||||
|
minijinja-autoreload = "1.0.14"
|
||||||
|
rand = "0.8.5"
|
||||||
|
sea-orm = { version = "0.12.15", features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"] }
|
||||||
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
|
thiserror = "1.0.58"
|
||||||
|
tokio = { version = "1.36.0", features = ["rt", "full"] }
|
||||||
|
tower-http = { version = "0.5.2", features = ["fs", "trace"] }
|
||||||
|
tower-sessions = "0.11.1"
|
||||||
|
tower-sessions-moka-store = "0.11.0"
|
||||||
|
tower-sessions-sqlx-store = { version = "0.11.0", features = ["sqlite"] }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||||
|
|
15
Makefile
Normal file
15
Makefile
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
|
||||||
|
run:
|
||||||
|
SECURE_SESSIONS=false RUST_LOG=debug cargo run -- --reload-templates
|
||||||
|
|
||||||
|
run-release:
|
||||||
|
SECURE_SESSIONS=false RUST_LOG=info cargo run --release
|
||||||
|
|
||||||
|
css-watch:
|
||||||
|
npx tailwindcss -i ./src/main.css -o ./static/main.css --watch
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
sea-orm-cli migrate
|
||||||
|
|
||||||
|
entities:
|
||||||
|
sea-orm-cli generate entity -o src/entity --with-serde both
|
14
README.md
14
README.md
|
@ -34,6 +34,20 @@ We use nightly, and installation and management using [rustup][rustup] is
|
||||||
recommended.
|
recommended.
|
||||||
|
|
||||||
|
|
||||||
|
### SeaORM
|
||||||
|
|
||||||
|
We use SeaORM for database interaction. You'll want the CLI, which you can
|
||||||
|
install with `cargo install sea-orm-cli`.
|
||||||
|
|
||||||
|
|
||||||
|
### Tailwind
|
||||||
|
|
||||||
|
We use Tailwind for our styling. You'll want to install the CLI:
|
||||||
|
```
|
||||||
|
npm install -D tailwindcss
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
Decisions are recorded in ADRs[^adr] using a command-line tool to create and
|
Decisions are recorded in ADRs[^adr] using a command-line tool to create and
|
||||||
|
|
29
_docs/decisions/0003-use-tailwind-for-css.md
Normal file
29
_docs/decisions/0003-use-tailwind-for-css.md
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# 3. Use Tailwind for CSS
|
||||||
|
|
||||||
|
Date: 2024-03-21
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We need to style our pages to make them look good.
|
||||||
|
|
||||||
|
Previously, I've used vanilla CSS, but that can get unwieldy and inconsistent. Here, a unified approach and one that can draw inspiration from other places (including purchasing component galleries) would be beneficial.
|
||||||
|
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We're going to use Tailwind CSS, and potentially Tailwind UI, to style the project.
|
||||||
|
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
This means that we will have a higher upfront cost to learn some of the Tailwind ways of doing things.
|
||||||
|
|
||||||
|
It also means that long-term it's easier to get help from people, and look at how some of the other projects I have interacted with do things if they are using Tailwind.
|
||||||
|
|
||||||
|
There is not much risk of lock-in here, because I can stop using Tailwind and go back to vanilla CSS fairly easily.
|
2351
migration/Cargo.lock
generated
Normal file
2351
migration/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
migration/Cargo.toml
Normal file
19
migration/Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "migration"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "migration"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||||
|
|
||||||
|
[dependencies.sea-orm-migration]
|
||||||
|
version = "0.12.0"
|
||||||
|
features = [
|
||||||
|
"runtime-tokio-rustls",
|
||||||
|
"sqlx-sqlite",
|
||||||
|
]
|
41
migration/README.md
Normal file
41
migration/README.md
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
# Running Migrator CLI
|
||||||
|
|
||||||
|
- Generate a new migration file
|
||||||
|
```sh
|
||||||
|
cargo run -- generate MIGRATION_NAME
|
||||||
|
```
|
||||||
|
- Apply all pending migrations
|
||||||
|
```sh
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
```sh
|
||||||
|
cargo run -- up
|
||||||
|
```
|
||||||
|
- Apply first 10 pending migrations
|
||||||
|
```sh
|
||||||
|
cargo run -- up -n 10
|
||||||
|
```
|
||||||
|
- Rollback last applied migrations
|
||||||
|
```sh
|
||||||
|
cargo run -- down
|
||||||
|
```
|
||||||
|
- Rollback last 10 applied migrations
|
||||||
|
```sh
|
||||||
|
cargo run -- down -n 10
|
||||||
|
```
|
||||||
|
- Drop all tables from the database, then reapply all migrations
|
||||||
|
```sh
|
||||||
|
cargo run -- fresh
|
||||||
|
```
|
||||||
|
- Rollback all applied migrations, then reapply all migrations
|
||||||
|
```sh
|
||||||
|
cargo run -- refresh
|
||||||
|
```
|
||||||
|
- Rollback all applied migrations
|
||||||
|
```sh
|
||||||
|
cargo run -- reset
|
||||||
|
```
|
||||||
|
- Check the status of all migrations
|
||||||
|
```sh
|
||||||
|
cargo run -- status
|
||||||
|
```
|
14
migration/src/lib.rs
Normal file
14
migration/src/lib.rs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
pub use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
mod m20240316_155147_create_users_table;
|
||||||
|
|
||||||
|
pub struct Migrator;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigratorTrait for Migrator {
|
||||||
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
|
vec![
|
||||||
|
Box::new(m20240316_155147_create_users_table::Migration),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
69
migration/src/m20240316_155147_create_users_table.rs
Normal file
69
migration/src/m20240316_155147_create_users_table.rs
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(User::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(User::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(User::FullName).string_len(100).not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(User::Email)
|
||||||
|
.string_len(100)
|
||||||
|
.unique()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(User::Username)
|
||||||
|
.string_len(32)
|
||||||
|
.unique()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(User::PasswordHash).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(User::Created)
|
||||||
|
.date_time()
|
||||||
|
.default(Expr::current_timestamp())
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(User::Updated)
|
||||||
|
.date_time()
|
||||||
|
.default(Expr::current_timestamp())
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(User::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum User {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
FullName,
|
||||||
|
Email,
|
||||||
|
Username,
|
||||||
|
PasswordHash,
|
||||||
|
Created,
|
||||||
|
Updated,
|
||||||
|
}
|
6
migration/src/main.rs
Normal file
6
migration/src/main.rs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[async_std::main]
|
||||||
|
async fn main() {
|
||||||
|
cli::run_cli(migration::Migrator).await;
|
||||||
|
}
|
1379
package-lock.json
generated
Normal file
1379
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
5
package.json
Normal file
5
package.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^3.4.1"
|
||||||
|
}
|
||||||
|
}
|
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
90
src/bin/admin.rs
Normal file
90
src/bin/admin.rs
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use pique::{
|
||||||
|
db::{NewUser, UserQuery},
|
||||||
|
prelude::*,
|
||||||
|
};
|
||||||
|
use rand::distributions::Alphanumeric;
|
||||||
|
use rand::{distributions::DistString, thread_rng};
|
||||||
|
use sea_orm::Database;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
pub async fn main() -> Result<()> {
|
||||||
|
dotenvy::dotenv()?;
|
||||||
|
|
||||||
|
match AdminCli::parse().command {
|
||||||
|
AdminCommand::CreateUser {
|
||||||
|
full_name,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
} => {
|
||||||
|
let password = match password {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
let mut rng = thread_rng();
|
||||||
|
let password = Alphanumeric.sample_string(&mut rng, 24);
|
||||||
|
|
||||||
|
println!("Generated password: {}", password);
|
||||||
|
password
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handle_create_user(NewUser {
|
||||||
|
full_name,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
AdminCommand::ListUsers => handle_list_users().await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
struct AdminCli {
|
||||||
|
#[clap(subcommand)]
|
||||||
|
pub command: AdminCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum AdminCommand {
|
||||||
|
CreateUser {
|
||||||
|
full_name: String,
|
||||||
|
email: String,
|
||||||
|
username: String,
|
||||||
|
password: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
ListUsers,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_create_user(new_user: NewUser) -> Result<()> {
|
||||||
|
let db = connect_to_db().await?;
|
||||||
|
|
||||||
|
let user = UserQuery(&db).insert(new_user).await?;
|
||||||
|
println!("User created successfully with id = {}", user.id.unwrap());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_list_users() -> Result<()> {
|
||||||
|
let db = connect_to_db().await?;
|
||||||
|
|
||||||
|
let users = UserQuery(&db).all().await?;
|
||||||
|
|
||||||
|
println!("Found {} users.", users.len());
|
||||||
|
for user in users {
|
||||||
|
println!(" > {}: {} ({})", user.id, user.username, user.full_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_to_db() -> Result<DatabaseConnection> {
|
||||||
|
let db_url = dotenvy::var("DATABASE_URL")?;
|
||||||
|
let db = Database::connect(db_url).await?;
|
||||||
|
Ok(db)
|
||||||
|
}
|
9
src/bin/pique.rs
Normal file
9
src/bin/pique.rs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use pique::server;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
pub async fn main() -> Result<()> {
|
||||||
|
server::run().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
7
src/config.rs
Normal file
7
src/config.rs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub struct CommandLineOptions {
|
||||||
|
#[arg(short, long, action)]
|
||||||
|
pub reload_templates: bool,
|
||||||
|
}
|
39
src/context.rs
Normal file
39
src/context.rs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use minijinja_autoreload::AutoReloader;
|
||||||
|
|
||||||
|
use crate::{handler::internal_server_error, prelude::*};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Context {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
template_loader: Arc<AutoReloader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Context {
|
||||||
|
pub fn new(db: DatabaseConnection, template_loader: AutoReloader) -> Context {
|
||||||
|
Context {
|
||||||
|
db,
|
||||||
|
template_loader: Arc::new(template_loader),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render<T: Serialize>(&self, path: &str, data: T) -> anyhow::Result<String> {
|
||||||
|
// TODO: more graceful handling of the potential errors here; this should not use anyhow
|
||||||
|
let env = self.template_loader.acquire_env().unwrap();
|
||||||
|
let template = env.get_template(path)?;
|
||||||
|
let rendered = template.render(data)?;
|
||||||
|
Ok(rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_resp<T: Serialize>(&self, path: &str, data: T) -> Response {
|
||||||
|
let rendered = self.render(path, data);
|
||||||
|
match rendered {
|
||||||
|
Ok(rendered) => Html(rendered).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
error!(?err, "error while rendering template");
|
||||||
|
internal_server_error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
96
src/db.rs
Normal file
96
src/db.rs
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
use sea_orm::Set;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{entity::user, password, prelude::*};
|
||||||
|
|
||||||
|
pub struct NewUser {
|
||||||
|
pub full_name: String,
|
||||||
|
pub email: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewUser {
|
||||||
|
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
|
||||||
|
let mut validation_errors = vec![];
|
||||||
|
|
||||||
|
if self.full_name.len() > 100 {
|
||||||
|
validation_errors.push(ValidationError::on("full_name", "too long (max=100)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.email.len() > 100 {
|
||||||
|
validation_errors.push(ValidationError::on("email", "too long (max=100)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.username.len() > 100 {
|
||||||
|
validation_errors.push(ValidationError::on("username", "too long (max=32)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if validation_errors.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(validation_errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_password(&self) -> String {
|
||||||
|
password::hash(&self.password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ValidationError {
|
||||||
|
pub field: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidationError {
|
||||||
|
pub fn on(field: &str, message: &str) -> ValidationError {
|
||||||
|
ValidationError {
|
||||||
|
field: field.to_owned(),
|
||||||
|
message: message.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DbError {
|
||||||
|
#[error("internal database error")]
|
||||||
|
Internal(#[from] sea_orm::DbErr),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserQuery<'a>(pub &'a DatabaseConnection);
|
||||||
|
|
||||||
|
impl UserQuery<'_> {
|
||||||
|
pub async fn insert(&self, new_user: NewUser) -> Result<user::ActiveModel, DbError> {
|
||||||
|
let password_hash = new_user.hash_password();
|
||||||
|
let user = user::ActiveModel {
|
||||||
|
full_name: Set(new_user.full_name),
|
||||||
|
email: Set(new_user.email),
|
||||||
|
username: Set(new_user.username),
|
||||||
|
password_hash: Set(password_hash),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.save(self.0)
|
||||||
|
.await?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn all(&self) -> Result<Vec<user::Model>, DbError> {
|
||||||
|
let users = User::find().all(self.0).await?;
|
||||||
|
Ok(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn by_id(&self, id: i32) -> Result<Option<user::Model>, DbError> {
|
||||||
|
let user = User::find_by_id(id).one(self.0).await?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn by_username(&self, username: &str) -> Result<Option<user::Model>, DbError> {
|
||||||
|
let user = User::find()
|
||||||
|
.filter(user::Column::Username.eq(username))
|
||||||
|
.one(self.0)
|
||||||
|
.await?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
5
src/entity/mod.rs
Normal file
5
src/entity/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||||
|
|
||||||
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod user;
|
3
src/entity/prelude.rs
Normal file
3
src/entity/prelude.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||||
|
|
||||||
|
pub use super::user::Entity as User;
|
22
src/entity/user.rs
Normal file
22
src/entity/user.rs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "user")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub full_name: String,
|
||||||
|
pub email: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub created: String,
|
||||||
|
pub updated: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
14
src/handler.rs
Normal file
14
src/handler.rs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
pub mod home;
|
||||||
|
pub mod login;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Response;
|
||||||
|
pub use login::login_page;
|
||||||
|
pub use login::login_submit;
|
||||||
|
|
||||||
|
pub fn internal_server_error() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Internal Server Error".into())
|
||||||
|
.unwrap()
|
||||||
|
}
|
16
src/handler/home.rs
Normal file
16
src/handler/home.rs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
use axum::response::Redirect;
|
||||||
|
use axum_login::AuthSession;
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
pub async fn home_page(State(ctx): State<Context>, auth_session: AuthSession<Context>) -> Response {
|
||||||
|
if let Some(user) = auth_session.user {
|
||||||
|
let values = context! {
|
||||||
|
user => user,
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.render_resp("home.html", values)
|
||||||
|
} else {
|
||||||
|
Redirect::to("/login").into_response()
|
||||||
|
}
|
||||||
|
}
|
121
src/handler/login.rs
Normal file
121
src/handler/login.rs
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
use axum::{response::Redirect, Form};
|
||||||
|
use axum_login::AuthSession;
|
||||||
|
|
||||||
|
use crate::{handler::internal_server_error, prelude::*, session::Credentials};
|
||||||
|
|
||||||
|
pub struct LoginTemplate {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_page(
|
||||||
|
State(ctx): State<Context>,
|
||||||
|
auth_session: AuthSession<Context>,
|
||||||
|
) -> Response {
|
||||||
|
if auth_session.user.is_some() {
|
||||||
|
return Redirect::to("/").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
render_login_page(&ctx, "", "", None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_login_page(
|
||||||
|
ctx: &Context,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
error: Option<&'static str>,
|
||||||
|
) -> Response {
|
||||||
|
ctx.render_resp(
|
||||||
|
"login.html",
|
||||||
|
context! {
|
||||||
|
username => username,
|
||||||
|
password => password,
|
||||||
|
error => error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOGIN_ERROR_MSG: &str = "Invalid username or password";
|
||||||
|
|
||||||
|
pub async fn login_submit(
|
||||||
|
State(ctx): State<Context>,
|
||||||
|
mut auth_session: AuthSession<Context>,
|
||||||
|
Form(creds): Form<Credentials>,
|
||||||
|
) -> Response {
|
||||||
|
match auth_session.authenticate(creds).await {
|
||||||
|
Ok(Some(user)) => {
|
||||||
|
if let Err(err) = auth_session.login(&user).await {
|
||||||
|
error!(?err, "error while logging in user");
|
||||||
|
return internal_server_error();
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => render_login_page(&ctx, "", "", Some(LOGIN_ERROR_MSG)),
|
||||||
|
Err(err) => {
|
||||||
|
error!(?err, "error while authenticating user");
|
||||||
|
internal_server_error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(mut auth_session: AuthSession<Context>) -> Response {
|
||||||
|
if let Err(err) = auth_session.logout().await {
|
||||||
|
error!(?err, "error while logging out user");
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to("/login").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
//const INVALID_LOGIN_MESSAGE: &str = "Invalid username/password, please try again.";
|
||||||
|
//
|
||||||
|
//pub async fn login_submission(
|
||||||
|
// request: HttpRequest,
|
||||||
|
// context: web::Data<Context>,
|
||||||
|
// form: web::Form<LoginForm>,
|
||||||
|
//) -> impl Responder {
|
||||||
|
// let mut conn = match context.pool.get() {
|
||||||
|
// Ok(conn) => conn,
|
||||||
|
// Err(_) => return internal_server_error(),
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// let user = match fetch_user_by_username(&mut conn, &form.username) {
|
||||||
|
// Ok(Some(user)) => user,
|
||||||
|
// Ok(None) => {
|
||||||
|
// return LoginTemplate {
|
||||||
|
// username: form.username.clone(),
|
||||||
|
// password: String::new(),
|
||||||
|
// error: Some(INVALID_LOGIN_MESSAGE.into()),
|
||||||
|
// }
|
||||||
|
// .to_response()
|
||||||
|
// }
|
||||||
|
// Err(_) => return internal_server_error(),
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// if !user.check_password(&form.password) {
|
||||||
|
// return LoginTemplate {
|
||||||
|
// username: form.username.clone(),
|
||||||
|
// password: String::new(),
|
||||||
|
// error: Some(INVALID_LOGIN_MESSAGE.into()),
|
||||||
|
// }
|
||||||
|
// .to_response();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if Identity::login(&request.extensions(), user.id.to_string()).is_err() {
|
||||||
|
// return internal_server_error();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return HttpResponse::Found()
|
||||||
|
// .append_header(("Location", "/"))
|
||||||
|
// .finish();
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//#[get("/logout")]
|
||||||
|
//pub async fn logout(user: Option<Identity>) -> impl Responder {
|
||||||
|
// if let Some(user) = user {
|
||||||
|
// user.logout();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// redirect_to_login()
|
||||||
|
//}
|
11
src/lib.rs
Normal file
11
src/lib.rs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
pub mod config;
|
||||||
|
pub mod context;
|
||||||
|
pub mod db;
|
||||||
|
pub mod entity;
|
||||||
|
pub mod handler;
|
||||||
|
pub mod logging;
|
||||||
|
pub mod password;
|
||||||
|
pub mod prelude;
|
||||||
|
pub mod server;
|
||||||
|
pub mod session;
|
||||||
|
pub mod templates;
|
5
src/logging.rs
Normal file
5
src/logging.rs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
pub fn setup_logging() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
|
.init();
|
||||||
|
}
|
3
src/main.css
Normal file
3
src/main.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
|
@ -1,3 +0,0 @@
|
||||||
fn main() {
|
|
||||||
println!("Hello, world!");
|
|
||||||
}
|
|
43
src/password.rs
Normal file
43
src/password.rs
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
use argon2::password_hash::rand_core::OsRng;
|
||||||
|
use argon2::password_hash::SaltString;
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||||
|
|
||||||
|
/// Verifies that the given password matches the given hash.
|
||||||
|
pub fn verify(hash: &str, password: &str) -> bool {
|
||||||
|
let parsed_hash = match PasswordHash::new(hash) {
|
||||||
|
Ok(hash) => hash,
|
||||||
|
Err(_) => return false, // TODO: log an error
|
||||||
|
};
|
||||||
|
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hashes the given password.
|
||||||
|
pub fn hash(password: &str) -> String {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
hash.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_password_hashes_match() {
|
||||||
|
// this is a well-known example password:
|
||||||
|
// https://knowyourmeme.com/memes/hunter2
|
||||||
|
let password = "hunter2";
|
||||||
|
let hashed_password = hash(password);
|
||||||
|
let hashed_other = hash("not-the-password");
|
||||||
|
|
||||||
|
assert!(verify(&hashed_password, password));
|
||||||
|
assert!(!verify(&hashed_other, password));
|
||||||
|
}
|
||||||
|
}
|
9
src/prelude.rs
Normal file
9
src/prelude.rs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
pub use crate::context::Context;
|
||||||
|
pub use crate::entity::prelude::*;
|
||||||
|
pub use axum::extract::State;
|
||||||
|
pub use axum::response::{Html, IntoResponse, Response};
|
||||||
|
pub use minijinja::context;
|
||||||
|
pub use sea_orm::prelude::*;
|
||||||
|
pub use sea_orm::{ActiveModelTrait, DatabaseConnection};
|
||||||
|
pub use serde::{Deserialize, Serialize};
|
||||||
|
pub use tracing::{debug, error, info, warn};
|
67
src/server.rs
Normal file
67
src/server.rs
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::{routing::{get, post}, Router};
|
||||||
|
use axum_login::AuthManagerLayerBuilder;
|
||||||
|
use clap::Parser;
|
||||||
|
use sea_orm::Database;
|
||||||
|
use tower_http::{services::ServeDir, trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}};
|
||||||
|
use tower_sessions::SessionManagerLayer;
|
||||||
|
use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore};
|
||||||
|
use tracing::Level;
|
||||||
|
|
||||||
|
use crate::{config::CommandLineOptions, context::Context, handler::{home::home_page, login::logout, login_page, login_submit}, logging::setup_logging, templates::make_template_loader};
|
||||||
|
|
||||||
|
pub async fn run() -> Result<()> {
|
||||||
|
dotenvy::dotenv()?;
|
||||||
|
setup_logging();
|
||||||
|
|
||||||
|
let opts = CommandLineOptions::parse();
|
||||||
|
|
||||||
|
let template_loader = make_template_loader(opts.reload_templates);
|
||||||
|
|
||||||
|
let db_url = dotenvy::var("DATABASE_URL")?;
|
||||||
|
let db = Database::connect(db_url).await?;
|
||||||
|
|
||||||
|
let session_layer = create_session_manager_layer().await?;
|
||||||
|
|
||||||
|
let context = Context::new(db, template_loader);
|
||||||
|
|
||||||
|
let auth_backend = context.clone();
|
||||||
|
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer.clone()).build();
|
||||||
|
|
||||||
|
let trace_layer = TraceLayer::new_for_http()
|
||||||
|
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||||
|
.on_response(DefaultOnResponse::new().level(Level::INFO));
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.nest_service("/static", ServeDir::new("static"))
|
||||||
|
.route("/", get(home_page))
|
||||||
|
.route("/login", get(login_page))
|
||||||
|
.route("/login", post(login_submit))
|
||||||
|
.route("/logout", get(logout))
|
||||||
|
.layer(trace_layer)
|
||||||
|
.layer(session_layer)
|
||||||
|
.layer(auth_layer)
|
||||||
|
.with_state(context);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_session_manager_layer() -> Result<SessionManagerLayer<SqliteStore>> {
|
||||||
|
let session_db_url =
|
||||||
|
dotenvy::var("SESSION_DB_URL").unwrap_or("sqlite:./sessions.db?mode=rwc".to_owned());
|
||||||
|
let pool = SqlitePool::connect(&session_db_url).await?;
|
||||||
|
let session_store = SqliteStore::new(pool);
|
||||||
|
session_store.migrate().await?;
|
||||||
|
|
||||||
|
let use_secure_sessions: bool =
|
||||||
|
FromStr::from_str(&dotenvy::var("SECURE_SESSIONS").unwrap_or("true".to_owned()))?;
|
||||||
|
|
||||||
|
let session_layer = SessionManagerLayer::new(session_store).with_secure(use_secure_sessions);
|
||||||
|
|
||||||
|
Ok(session_layer)
|
||||||
|
}
|
49
src/session.rs
Normal file
49
src/session.rs
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{DbError, UserQuery},
|
||||||
|
entity::user,
|
||||||
|
password,
|
||||||
|
prelude::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthUser for user::Model {
|
||||||
|
type Id = i32;
|
||||||
|
|
||||||
|
fn id(&self) -> Self::Id {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_auth_hash(&self) -> &[u8] {
|
||||||
|
self.password_hash.as_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthnBackend for Context {
|
||||||
|
type User = user::Model;
|
||||||
|
type Credentials = Credentials;
|
||||||
|
type Error = DbError;
|
||||||
|
|
||||||
|
async fn authenticate(
|
||||||
|
&self,
|
||||||
|
creds: Self::Credentials,
|
||||||
|
) -> Result<Option<Self::User>, Self::Error> {
|
||||||
|
let user = UserQuery(&self.db)
|
||||||
|
.by_username(&creds.username)
|
||||||
|
.await?
|
||||||
|
.filter(|u| password::verify(&u.password_hash, &creds.password));
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||||
|
UserQuery(&self.db).by_id(*user_id).await
|
||||||
|
}
|
||||||
|
}
|
15
src/templates.rs
Normal file
15
src/templates.rs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
use minijinja::{path_loader, Environment};
|
||||||
|
use minijinja_autoreload::AutoReloader;
|
||||||
|
|
||||||
|
pub fn make_template_loader(auto_reload: bool) -> AutoReloader {
|
||||||
|
let reloader = AutoReloader::new(move |notifier| {
|
||||||
|
let mut env = Environment::new();
|
||||||
|
let templates_path = "templates/";
|
||||||
|
env.set_loader(path_loader(templates_path));
|
||||||
|
if auto_reload {
|
||||||
|
notifier.watch_path(templates_path, true);
|
||||||
|
}
|
||||||
|
Ok(env)
|
||||||
|
});
|
||||||
|
reloader
|
||||||
|
}
|
0
static/.gitkeep
Normal file
0
static/.gitkeep
Normal file
646
static/main.css
Normal file
646
static/main.css
Normal file
|
@ -0,0 +1,646 @@
|
||||||
|
/*
|
||||||
|
! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||||
|
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||||
|
*/
|
||||||
|
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* 1 */
|
||||||
|
border-width: 0;
|
||||||
|
/* 2 */
|
||||||
|
border-style: solid;
|
||||||
|
/* 2 */
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
--tw-content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Use a consistent sensible line-height in all browsers.
|
||||||
|
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||||
|
3. Use a more readable tab size.
|
||||||
|
4. Use the user's configured `sans` font-family by default.
|
||||||
|
5. Use the user's configured `sans` font-feature-settings by default.
|
||||||
|
6. Use the user's configured `sans` font-variation-settings by default.
|
||||||
|
7. Disable tap highlights on iOS
|
||||||
|
*/
|
||||||
|
|
||||||
|
html,
|
||||||
|
:host {
|
||||||
|
line-height: 1.5;
|
||||||
|
/* 1 */
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
/* 2 */
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
/* 3 */
|
||||||
|
-o-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
/* 3 */
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
/* 4 */
|
||||||
|
font-feature-settings: normal;
|
||||||
|
/* 5 */
|
||||||
|
font-variation-settings: normal;
|
||||||
|
/* 6 */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
/* 7 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Remove the margin in all browsers.
|
||||||
|
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
/* 1 */
|
||||||
|
line-height: inherit;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Add the correct height in Firefox.
|
||||||
|
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||||
|
3. Ensure horizontal rules are visible by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 0;
|
||||||
|
/* 1 */
|
||||||
|
color: inherit;
|
||||||
|
/* 2 */
|
||||||
|
border-top-width: 1px;
|
||||||
|
/* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
abbr:where([title]) {
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the default font size and weight for headings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reset links to optimize for opt-in styling instead of opt-out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct font weight in Edge and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Use the user's configured `mono` font-family by default.
|
||||||
|
2. Use the user's configured `mono` font-feature-settings by default.
|
||||||
|
3. Use the user's configured `mono` font-variation-settings by default.
|
||||||
|
4. Correct the odd `em` font sizing in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
/* 1 */
|
||||||
|
font-feature-settings: normal;
|
||||||
|
/* 2 */
|
||||||
|
font-variation-settings: normal;
|
||||||
|
/* 3 */
|
||||||
|
font-size: 1em;
|
||||||
|
/* 4 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct font size in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||||
|
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||||
|
3. Remove gaps between table borders by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
table {
|
||||||
|
text-indent: 0;
|
||||||
|
/* 1 */
|
||||||
|
border-color: inherit;
|
||||||
|
/* 2 */
|
||||||
|
border-collapse: collapse;
|
||||||
|
/* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Change the font styles in all browsers.
|
||||||
|
2. Remove the margin in Firefox and Safari.
|
||||||
|
3. Remove default padding in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
/* 1 */
|
||||||
|
font-feature-settings: inherit;
|
||||||
|
/* 1 */
|
||||||
|
font-variation-settings: inherit;
|
||||||
|
/* 1 */
|
||||||
|
font-size: 100%;
|
||||||
|
/* 1 */
|
||||||
|
font-weight: inherit;
|
||||||
|
/* 1 */
|
||||||
|
line-height: inherit;
|
||||||
|
/* 1 */
|
||||||
|
color: inherit;
|
||||||
|
/* 1 */
|
||||||
|
margin: 0;
|
||||||
|
/* 2 */
|
||||||
|
padding: 0;
|
||||||
|
/* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the inheritance of text transform in Edge and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
2. Remove default button styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type='button'],
|
||||||
|
[type='reset'],
|
||||||
|
[type='submit'] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
/* 1 */
|
||||||
|
background-color: transparent;
|
||||||
|
/* 2 */
|
||||||
|
background-image: none;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Use the modern Firefox focus style for all focusable elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:-moz-focusring {
|
||||||
|
outline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
|
||||||
|
*/
|
||||||
|
|
||||||
|
:-moz-ui-invalid {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct vertical alignment in Chrome and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Correct the cursor style of increment and decrement buttons in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button,
|
||||||
|
::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Correct the odd appearance in Chrome and Safari.
|
||||||
|
2. Correct the outline style in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type='search'] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
/* 1 */
|
||||||
|
outline-offset: -2px;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the inner padding in Chrome and Safari on macOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
2. Change font properties to `inherit` in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
/* 1 */
|
||||||
|
font: inherit;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct display in Chrome and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Removes the default spacing and border for appropriate elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
blockquote,
|
||||||
|
dl,
|
||||||
|
dd,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
hr,
|
||||||
|
figure,
|
||||||
|
p,
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
menu {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reset default styling for dialogs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Prevent resizing textareas horizontally by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
|
||||||
|
2. Set the default placeholder color to the user's configured gray 400 color.
|
||||||
|
*/
|
||||||
|
|
||||||
|
input::-moz-placeholder, textarea::-moz-placeholder {
|
||||||
|
opacity: 1;
|
||||||
|
/* 1 */
|
||||||
|
color: #9ca3af;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
opacity: 1;
|
||||||
|
/* 1 */
|
||||||
|
color: #9ca3af;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Set the default cursor for buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
[role="button"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Make sure disabled buttons don't get the pointer cursor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||||
|
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
|
||||||
|
This can trigger a poorly considered lint error in some tools but is included by design.
|
||||||
|
*/
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg,
|
||||||
|
video,
|
||||||
|
canvas,
|
||||||
|
audio,
|
||||||
|
iframe,
|
||||||
|
embed,
|
||||||
|
object {
|
||||||
|
display: block;
|
||||||
|
/* 1 */
|
||||||
|
vertical-align: middle;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||||
|
*/
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make elements with the HTML hidden attribute stay hidden by default */
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, ::before, ::after {
|
||||||
|
--tw-border-spacing-x: 0;
|
||||||
|
--tw-border-spacing-y: 0;
|
||||||
|
--tw-translate-x: 0;
|
||||||
|
--tw-translate-y: 0;
|
||||||
|
--tw-rotate: 0;
|
||||||
|
--tw-skew-x: 0;
|
||||||
|
--tw-skew-y: 0;
|
||||||
|
--tw-scale-x: 1;
|
||||||
|
--tw-scale-y: 1;
|
||||||
|
--tw-pan-x: ;
|
||||||
|
--tw-pan-y: ;
|
||||||
|
--tw-pinch-zoom: ;
|
||||||
|
--tw-scroll-snap-strictness: proximity;
|
||||||
|
--tw-gradient-from-position: ;
|
||||||
|
--tw-gradient-via-position: ;
|
||||||
|
--tw-gradient-to-position: ;
|
||||||
|
--tw-ordinal: ;
|
||||||
|
--tw-slashed-zero: ;
|
||||||
|
--tw-numeric-figure: ;
|
||||||
|
--tw-numeric-spacing: ;
|
||||||
|
--tw-numeric-fraction: ;
|
||||||
|
--tw-ring-inset: ;
|
||||||
|
--tw-ring-offset-width: 0px;
|
||||||
|
--tw-ring-offset-color: #fff;
|
||||||
|
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow-colored: 0 0 #0000;
|
||||||
|
--tw-blur: ;
|
||||||
|
--tw-brightness: ;
|
||||||
|
--tw-contrast: ;
|
||||||
|
--tw-grayscale: ;
|
||||||
|
--tw-hue-rotate: ;
|
||||||
|
--tw-invert: ;
|
||||||
|
--tw-saturate: ;
|
||||||
|
--tw-sepia: ;
|
||||||
|
--tw-drop-shadow: ;
|
||||||
|
--tw-backdrop-blur: ;
|
||||||
|
--tw-backdrop-brightness: ;
|
||||||
|
--tw-backdrop-contrast: ;
|
||||||
|
--tw-backdrop-grayscale: ;
|
||||||
|
--tw-backdrop-hue-rotate: ;
|
||||||
|
--tw-backdrop-invert: ;
|
||||||
|
--tw-backdrop-opacity: ;
|
||||||
|
--tw-backdrop-saturate: ;
|
||||||
|
--tw-backdrop-sepia: ;
|
||||||
|
}
|
||||||
|
|
||||||
|
::backdrop {
|
||||||
|
--tw-border-spacing-x: 0;
|
||||||
|
--tw-border-spacing-y: 0;
|
||||||
|
--tw-translate-x: 0;
|
||||||
|
--tw-translate-y: 0;
|
||||||
|
--tw-rotate: 0;
|
||||||
|
--tw-skew-x: 0;
|
||||||
|
--tw-skew-y: 0;
|
||||||
|
--tw-scale-x: 1;
|
||||||
|
--tw-scale-y: 1;
|
||||||
|
--tw-pan-x: ;
|
||||||
|
--tw-pan-y: ;
|
||||||
|
--tw-pinch-zoom: ;
|
||||||
|
--tw-scroll-snap-strictness: proximity;
|
||||||
|
--tw-gradient-from-position: ;
|
||||||
|
--tw-gradient-via-position: ;
|
||||||
|
--tw-gradient-to-position: ;
|
||||||
|
--tw-ordinal: ;
|
||||||
|
--tw-slashed-zero: ;
|
||||||
|
--tw-numeric-figure: ;
|
||||||
|
--tw-numeric-spacing: ;
|
||||||
|
--tw-numeric-fraction: ;
|
||||||
|
--tw-ring-inset: ;
|
||||||
|
--tw-ring-offset-width: 0px;
|
||||||
|
--tw-ring-offset-color: #fff;
|
||||||
|
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow-colored: 0 0 #0000;
|
||||||
|
--tw-blur: ;
|
||||||
|
--tw-brightness: ;
|
||||||
|
--tw-contrast: ;
|
||||||
|
--tw-grayscale: ;
|
||||||
|
--tw-hue-rotate: ;
|
||||||
|
--tw-invert: ;
|
||||||
|
--tw-saturate: ;
|
||||||
|
--tw-sepia: ;
|
||||||
|
--tw-drop-shadow: ;
|
||||||
|
--tw-backdrop-blur: ;
|
||||||
|
--tw-backdrop-brightness: ;
|
||||||
|
--tw-backdrop-contrast: ;
|
||||||
|
--tw-backdrop-grayscale: ;
|
||||||
|
--tw-backdrop-hue-rotate: ;
|
||||||
|
--tw-backdrop-invert: ;
|
||||||
|
--tw-backdrop-opacity: ;
|
||||||
|
--tw-backdrop-saturate: ;
|
||||||
|
--tw-backdrop-sepia: ;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-h-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-sm {
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shrink {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-0 {
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-row {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gray-200 {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgb(229 231 235 / var(--tw-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-white {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-black {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-purple-50 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(250 245 255 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-purple-100 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(243 232 255 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-12 {
|
||||||
|
padding-top: 3rem;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-lg {
|
||||||
|
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.dark\:bg-gray-900 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
}
|
9
tailwind.config.js
Normal file
9
tailwind.config.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./templates/**/*.html"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
8
templates/head.html
Normal file
8
templates/head.html
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pique</title>
|
||||||
|
<link rel="stylesheet" href="/static/main.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👀</text></svg>"/>
|
||||||
|
</head>
|
9
templates/home.html
Normal file
9
templates/home.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
{% include "head.html" %}
|
||||||
|
<body>
|
||||||
|
{% include "top_bar.html" %}
|
||||||
|
|
||||||
|
Hi there, {{ user.full_name }}!
|
||||||
|
</body>
|
||||||
|
</html>
|
35
templates/login.html
Normal file
35
templates/login.html
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
{% include "head.html" %}
|
||||||
|
<body class="min-h-screen bg-purple-100">
|
||||||
|
<div class="h-full">
|
||||||
|
<div class="flex min-h-screen flex-1 flex-col justify-center py-12">
|
||||||
|
<div class="flex flex-1 flex-row justify-center grow-0">
|
||||||
|
<div class="max-w-sm rounded overflow-hidden shadow-lg bg-white">
|
||||||
|
<form method="post" class="">
|
||||||
|
<h1 class="text-xl">Sign In to Pique</h1>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" name="username" placeholder="Username" value="{{ username }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" name="password" placeholder="Password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="">
|
||||||
|
<input type="submit" value="Log In">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
templates/top_bar.html
Normal file
8
templates/top_bar.html
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<div class="nav dark">
|
||||||
|
<div class="nav-elem">
|
||||||
|
<a href="/">Pique</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-elem">
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
Loading…
Reference in a new issue