Compare commits

..

15 Commits

46 changed files with 1006 additions and 534 deletions

View File

@ -6,8 +6,10 @@ SQLX_OFFLINE=true
# 64 byte long openssl rand -base64 64
SECRET_KEY="changeInProdOrHandAb11111111111111111111111111111111111111111111"
HOSTNAME="localhost"
WEBMASTER_EMAIL="admin@example.com"
SERVER_ADDRESS="127.0.0.1"
SERVER_PORT="8080"
APP_ENVIRONMENT="development"
SMTP_SERVER="localhost"
SMTP_PORT="1025"

View File

@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n availability.id,\n availability.userId,\n availability.startTimestamp,\n availability.endTimestamp,\n availability.comment\n FROM availability\n WHERE availability.userId = $1\n AND (availability.endtimestamp = $2\n OR availability.starttimestamp = $3)\n AND (availability.id <> $4 OR $4 IS NULL);\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "userid",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "starttimestamp",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "endtimestamp",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "comment",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int4",
"Timestamptz",
"Timestamptz",
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95"
}

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM user_ WHERE email = $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "b4edfcac9404060d487db765b8c18ef8b7440699583e0bede95f4d214e668a87"
}

2
Cargo.lock generated
View File

@ -797,7 +797,7 @@ dependencies = [
[[package]]
name = "brass-web"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"actix-files",
"actix-http",

102
README.md
View File

@ -1,69 +1,49 @@
# Brass
A webservice to plan and organize personnel deployment for [Brandsicherheitswachen](https://de.wikipedia.org/wiki/Brandsicherheitswache) (german; fire watch).
# Key Technologies
- [actix-web](https://actix.rs/)
- [sqlx](https://github.com/launchbadge/sqlx)
- [askama](https://github.com/askama-rs/askama)
- [lettre](https://lettre.rs/)
- [htmx](https://htmx.org/)
- [hyperscript](https://hyperscript.org/)
- [bulma](https://bulma.io/)
- great inspiration for project structure and tooling: [gerust.rs](https://gerust.rs)
# Getting started with developing
1. Clone the repository.
2. Install and configure Postgresql. Create a new database for brass: `createdb brass`.
3. TODO: Configure DB name, DB user & pass, DB connection string, ...
4. Install sqlx-cli: `cargo install sqlx-cli`
5. Migrate the database: `sqlx database setup`
6. Create superuse: `cargo r -- createadmin`
3. Configure database connection string in `.env` config file.
4. Install required development tools `cargo install <tool>`
- sqlx-cli
- mailtutan
- cargo-watch
- cargo-nextest
6. Migrate the development and test database: `cargo db migrate -e development` & `cargo db migrate -e test`
7. Create superuser: `cargo r -- createadmin`
8. Run and recompile application on file change: `cargo w`
9. Run tests via nextest and review possible snapshot changes: `cargo t`
## Useful stuff
- cargo-watch, cargo-add
- mailtutan
# Build & Deploy
1. Clone the repository.
2. Build release `cargo b --release`.
3. Copy the artifact `target/release/brass-web` to the desired location. Make it executable `chmod +x brass-web`.
4. Create Postgresql database on the target host, configure your mail server.
5. Configuration for Brass is done via Environment Variables, see `.env` for a list.
6. Migrate the database `[LIST_OF_ENV_VARIABLES] brass-web migrate`.
7. Create a superuser `[LIST_OF_ENV_VARIABLES] brass-web createadmin`.
8. Create some sort of service file (systemd .service, openbsd rc.conf, ...) to run Brass in the background. Examples can be found in `docs/` directory.
# Contributing & Issues
Code lies on my private gitea instance, thus there's no easy way for creating issues or making contributions. If you've got an issue or want to contribute, write me an email and we'll figure it out.
## Example Deployment OpenBSD
```
#!/bin/ksh
# Project Structure
- TODO
DATABASE_URL=postgresql://brass:pw@localhost/brass
SECRET_KEY=""
HOSTNAME="brass.tfld.de"
SERVER_ADDRESS="127.0.0.1"
SERVER_PORT="8081"
SMTP_SERVER="localhost"
SMTP_PORT="25"
SMTP_TLSTYPE="none"
ENVLIST="DATABASE_URL=$DATABASE_URL SECRET_KEY=$SECRET_KEY HOSTNAME=$HOSTNAME SERVER_ADDRESS=$SERVER_ADDRESS SERVER_PORT=$SERVER_PORT SMTP_SERVER=$SMTP_SERVER SMTP_PORT=$SMTP_PORT SMTP_TLSTYPE=$SMTP_TLSTYPE"
RUST_LOG="info,actix_server=error"
# Further Reading
More in depth documentation about design decisions, helpful commands and database schema can be found in `docs/` directory.
ENVLIST="DATABASE_URL=$DATABASE_URL SECRET_KEY=$SECRET_KEY HOSTNAME=$HOSTNAME SERVER_ADDRESS=$SERVER_ADDRESS SERVER_PORT=$SERVER_PORT SMTP_SERVER=$SMTP_SERVER SMTP_LOGIN=$SMTP_LOGIN SMTP_PASSWORD=$SMTP_PASSWORD SMTP_PORT=$SMTP_PORT SMTP_TLSTYPE=$SMTP_TLSTYPE RUST_LOG=$RUST_LOG"
daemon="$ENVLIST /usr/local/bin/brass"
daemon_user="www"
daemon_logger="daemon.info"
. /etc/rc.d/rc.subr
pexp=".*/usr/local/bin/brass.*"
rc_bg=YES
rc_cmd $1
```
```ini
# Postgres
# DATABASE_URL=postgres://postgres@localhost/my_database
# SQLite
DATABASE_URL=postgresql://brass:password@localhost/brass
# 64 byte long
SECRET_KEY="secret key"
HOSTNAME="brass.tfld.de"
ADDRESS="127.0.0.1"
PORT="8081"
SMTP_SERVER="localhost"
SMTP_PORT="25"
# SMTP_LOGIN=""
# SMTP_PASSWORD=""
SMTP_TLSTYPE="none"
```
## drop test databases
```bash
for dbname in $(psql -c "copy (select datname from pg_database where datname like 'brass_test_%') to stdout") ; do
echo "$dbname"
#dropdb -i "$dbname"
done
```
# Copyright & License
Copyright 2025 Max Hohlfeld
Brass is licensed under [GNU AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html#license-text).

Binary file not shown.

View File

@ -0,0 +1,8 @@
# Drop all test databases
```bash
for dbname in $(psql -c "copy (select datname from pg_database where datname like 'brass_test_%') to stdout") ; do
echo "$dbname"
#dropdb -i "$dbname"
done
```

View File

@ -0,0 +1,31 @@
# Example Deployment OpenBSD
- list of env variables may not be up to date
```sh
#!/bin/ksh
DATABASE_URL=postgresql://brass:pw@localhost/brass
SECRET_KEY=""
HOSTNAME="brass.tfld.de"
SERVER_ADDRESS="127.0.0.1"
SERVER_PORT="8081"
SMTP_SERVER="localhost"
SMTP_PORT="25"
SMTP_TLSTYPE="none"
ENVLIST="DATABASE_URL=$DATABASE_URL SECRET_KEY=$SECRET_KEY HOSTNAME=$HOSTNAME SERVER_ADDRESS=$SERVER_ADDRESS SERVER_PORT=$SERVER_PORT SMTP_SERVER=$SMTP_SERVER SMTP_PORT=$SMTP_PORT SMTP_TLSTYPE=$SMTP_TLSTYPE"
RUST_LOG="info,actix_server=error"
ENVLIST="DATABASE_URL=$DATABASE_URL SECRET_KEY=$SECRET_KEY HOSTNAME=$HOSTNAME SERVER_ADDRESS=$SERVER_ADDRESS SERVER_PORT=$SERVER_PORT SMTP_SERVER=$SMTP_SERVER SMTP_LOGIN=$SMTP_LOGIN SMTP_PASSWORD=$SMTP_PASSWORD SMTP_PORT=$SMTP_PORT SMTP_TLSTYPE=$SMTP_TLSTYPE RUST_LOG=$RUST_LOG"
daemon="$ENVLIST /usr/local/bin/brass"
daemon_user="www"
daemon_logger="daemon.info"
. /etc/rc.d/rc.subr
pexp=".*/usr/local/bin/brass.*"
rc_bg=YES
rc_cmd $1
```

View File

@ -1,6 +1,6 @@
[package]
name = "brass-web"
version = "1.0.0"
version = "1.0.1"
edition = "2021"
license = "AGPL-3.0"
authors = ["Max Hohlfeld <maxhohlfeld@posteo.de>"]
@ -37,6 +37,7 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tracing-panic = "0.1.2"
rust_xlsxwriter = "0.87.0"
regex = "1.11.1"
[build-dependencies]
built = "0.7.4"

View File

@ -15,30 +15,6 @@ snapshot_kind: text
<a class="panel-block is-active">
<div class="level">
<div class="level-left">
<div class="level-item">
Tuchuniform
</div>
<div class="level-item buttons are-small">
<button class="button is-success is-light" hx-get="/clothing/edit/1" hx-target="closest a">
<svg class="icon">
<use href="/static/feather-sprite.svg#edit" />
</svg>
</button>
<button class="button is-danger is-light" hx-delete="/clothing/1" hx-swap="delete"
hx-target="closest a" hx-trigger="confirmed">
<svg class="icon">
<use href="/static/feather-sprite.svg#trash-2" />
</svg>
</button>
</div>
</div>
</div>
</a>
<a class="panel-block is-active">
<div class="level">
<div class="level-left">
<div class="level-item">
Schutzkleidung Form 1
</div>
@ -60,6 +36,30 @@ snapshot_kind: text
</div>
</a>
<a class="panel-block is-active">
<div class="level">
<div class="level-left">
<div class="level-item">
Tuchuniform
</div>
<div class="level-item buttons are-small">
<button class="button is-success is-light" hx-get="/clothing/edit/1" hx-target="closest a">
<svg class="icon">
<use href="/static/feather-sprite.svg#edit" />
</svg>
</button>
<button class="button is-danger is-light" hx-delete="/clothing/1" hx-swap="delete"
hx-target="closest a" hx-trigger="confirmed">
<svg class="icon">
<use href="/static/feather-sprite.svg#trash-2" />
</svg>
</button>
</div>
</div>
</div>
</a>
<div class="panel-block">
<button class="button is-link is-light" hx-get="/clothing/new" hx-swap="beforebegin" hx-target="closest div">
<svg class="icon">

View File

@ -124,7 +124,7 @@ snapshot_kind: text
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Führungsassistent durch FF gestellt?</label>
<label class="label">Führungsassistent benötigt?</label>
</div>
<div class="field-body">
<div class="field is-narrow">

View File

@ -3,7 +3,7 @@ use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use serde::Deserialize;
use crate::filters;
use crate::models::{Availability, AvailabilityChangeset, Role, User};
use crate::models::{Role, User};
use crate::utils::DateTimeFormat::{DayMonth, DayMonthYear, DayMonthYearHourMinute, HourMinute};
pub mod delete;
@ -35,16 +35,3 @@ pub struct AvailabilityForm {
pub endtime: NaiveTime,
pub comment: Option<String>,
}
fn find_adjacend_availability<'a>(
changeset: &AvailabilityChangeset,
availability_id_to_be_updated: Option<i32>,
existing_availabilities: &'a [Availability],
) -> Option<&'a Availability> {
let existing_availability = existing_availabilities
.iter()
.filter(|a| availability_id_to_be_updated.is_none_or(|id| a.id != id))
.find(|a| a.start == changeset.time.1 || a.end == changeset.time.0);
return existing_availability;
}

View File

@ -1,11 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::availability::{find_adjacend_availability, AvailabilityForm},
endpoints::availability::AvailabilityForm,
models::{Availability, AvailabilityChangeset, AvailabilityContext, User},
utils::{self, ApplicationError},
utils::{self, validation::AsyncValidate, ApplicationError},
};
#[actix_web::post("/availability/new")]
@ -14,32 +13,33 @@ pub async fn post(
pool: web::Data<PgPool>,
form: web::Form<AvailabilityForm>,
) -> Result<impl Responder, ApplicationError> {
let existing_availabilities =
Availability::read_by_user_and_date(pool.get_ref(), user.id, &form.startdate).await?;
let context = AvailabilityContext {
existing_availabilities: existing_availabilities.clone(),
};
let start = form.startdate.and_time(form.starttime);
let end = form.enddate.and_time(form.endtime);
let context = AvailabilityContext {
pool: pool.get_ref(),
user_id: user.id,
availability_to_get_edited: None,
};
let mut changeset = AvailabilityChangeset {
time: (start, end),
comment: form.comment.clone(),
};
if let Err(e) = changeset.validate_with(&context) {
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
};
if let Some(a) = find_adjacend_availability(&changeset, None, &existing_availabilities) {
let (changeset_start, changeset_end) = changeset.time;
if a.end == changeset_start {
if let Some(a) =
Availability::find_adjacent_by_time_for_user(pool.get_ref(), &start, &end, user.id, None)
.await?
{
if a.end == start {
changeset.time.0 = a.start;
}
if a.start == changeset_end {
if a.start == end {
changeset.time.1 = a.end;
}

View File

@ -1,14 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::{
availability::{find_adjacend_availability, AvailabilityForm},
IdPath,
},
endpoints::{availability::AvailabilityForm, IdPath},
models::{Availability, AvailabilityChangeset, AvailabilityContext, User},
utils::{self, ApplicationError},
utils::{self, validation::AsyncValidate, ApplicationError},
};
#[actix_web::post("/availability/edit/{id}")]
@ -26,39 +22,38 @@ pub async fn post(
return Err(ApplicationError::Unauthorized);
}
let existing_availabilities: Vec<Availability> =
Availability::read_by_user_and_date(pool.get_ref(), user.id, &availability.start.date())
.await?
.into_iter()
.filter(|a| a.id != availability.id)
.collect();
let context = AvailabilityContext {
existing_availabilities: existing_availabilities.clone(),
};
let start = form.startdate.and_time(form.starttime);
let end = form.enddate.and_time(form.endtime);
let context = AvailabilityContext {
pool: pool.get_ref(),
user_id: user.id,
availability_to_get_edited: Some(availability.id),
};
let mut changeset = AvailabilityChangeset {
time: (start, end),
comment: form.comment.clone(),
};
if let Err(e) = changeset.validate_with(&context) {
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
};
if let Some(a) =
find_adjacend_availability(&changeset, Some(availability.id), &existing_availabilities)
if let Some(a) = Availability::find_adjacent_by_time_for_user(
pool.get_ref(),
&start,
&end,
user.id,
Some(availability.id),
)
.await?
{
let (changeset_start, changeset_end) = changeset.time;
if a.end == changeset_start {
if a.end == start {
changeset.time.0 = a.start;
}
if a.start == changeset_end {
if a.start == end {
changeset.time.1 = a.end;
}

View File

@ -1,6 +1,6 @@
use actix_web::{web, Responder};
use askama::Template;
use chrono::{Datelike, NaiveDate, Utc};
use chrono::{Datelike, Months, NaiveDate, Utc};
use sqlx::PgPool;
use crate::{
@ -14,6 +14,10 @@ struct EventExportTemplate {
user: User,
areas: Option<Vec<Area>>,
daterange: (NaiveDate, NaiveDate),
current_month: (NaiveDate, NaiveDate),
current_quarter: (NaiveDate, NaiveDate),
next_month: (NaiveDate, NaiveDate),
next_quarter: (NaiveDate, NaiveDate),
}
#[actix_web::get("/export/events")]
@ -32,17 +36,66 @@ pub async fn get(
};
let today = Utc::now().date_naive();
let start = NaiveDate::from_ymd_opt(today.year(), today.month0() + 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(today.year(), today.month0() + 2, 1)
.unwrap()
.pred_opt()
.unwrap();
let today_plus_month = today.checked_add_months(Months::new(1)).unwrap();
let today_plus_three_month = today.checked_add_months(Months::new(3)).unwrap();
let template = EventExportTemplate {
user: user.into_inner(),
areas,
daterange: (start, end),
daterange: (
first_day_of_month(&today).unwrap(),
last_day_of_month(&today).unwrap(),
),
current_month: (
first_day_of_month(&today).unwrap(),
last_day_of_month(&today).unwrap(),
),
next_month: (
first_day_of_month(&today_plus_month).unwrap(),
last_day_of_month(&today_plus_month).unwrap(),
),
current_quarter: (
first_day_of_quarter(&today).unwrap(),
last_day_of_quarter(&today).unwrap(),
),
next_quarter: (
first_day_of_quarter(&today_plus_three_month).unwrap(),
last_day_of_quarter(&today_plus_three_month).unwrap(),
),
};
Ok(template.to_response()?)
}
fn first_day_of_month(date: &NaiveDate) -> Option<NaiveDate> {
NaiveDate::from_ymd_opt(date.year(), date.month0() + 1, 1)
}
fn last_day_of_month(date: &NaiveDate) -> Option<NaiveDate> {
let month0 = date.month0() + 1;
let year = if month0 > 11 {
date.year() + 1
} else {
date.year()
};
let month = (month0 % 12) + 1;
NaiveDate::from_ymd_opt(year, month, 1)?.pred_opt()
}
fn first_day_of_quarter(date: &NaiveDate) -> Option<NaiveDate> {
let start_month = (date.quarter() * 3) - 2;
NaiveDate::from_ymd_opt(date.year(), start_month, 1)
}
fn last_day_of_quarter(date: &NaiveDate) -> Option<NaiveDate> {
let quarter = date.quarter();
let (year, next_month) = if quarter == 4 {
(date.year() + 1, 1)
} else {
(date.year(), (quarter * 3) + 1)
};
NaiveDate::from_ymd_opt(year, next_month, 1)?.pred_opt()
}

View File

@ -15,7 +15,7 @@ use crate::{
struct ExportQuery {
start: NaiveDate,
end: NaiveDate,
area: i32,
area: Option<i32>,
}
#[derive(PartialEq)]
@ -131,10 +131,12 @@ pub async fn get(
return Err(ApplicationError::Unauthorized);
}
let area = query.area.unwrap_or(user.area_id);
let rows_to_export = ExportEventRow::read_all_for_timerange_and_area(
pool.get_ref(),
(query.start, query.end),
query.area,
area,
)
.await?;
let entries = read(rows_to_export);
@ -154,10 +156,10 @@ pub async fn get(
"Fkt",
"Reserve",
];
worksheet.write_row(2, 0, HEADER).unwrap();
worksheet.write_row(0, 0, HEADER).unwrap();
for (i, entry) in entries.iter().enumerate() {
let i = (i + 3) as u32;
let i = (i + 1) as u32;
worksheet.write(i, 0, &entry.date).unwrap();
worksheet.write(i, 1, &entry.weekday).unwrap();

View File

@ -7,6 +7,7 @@ use crate::utils::{ApplicationError, Customization, TemplateResponse};
#[template(path = "imprint.html")]
struct ImprintTemplate {
webmaster_mail: String,
hostname: String,
}
#[actix_web::get("/imprint")]
@ -15,6 +16,7 @@ pub async fn get_imprint(
) -> Result<impl Responder, ApplicationError> {
let template = ImprintTemplate {
webmaster_mail: customization.webmaster_email.clone(),
hostname: customization.hostname.clone(),
};
Ok(template.to_response()?)

View File

@ -1,11 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::{user::NewOrEditUserForm, IdPath},
models::{Area, Function, Role, User, UserChangeset},
utils::ApplicationError,
models::{Function, Role, User, UserChangeset},
utils::{validation::{AsyncValidate, DbContext}, ApplicationError},
};
#[actix_web::post("/users/edit/{id}")]
@ -28,14 +27,12 @@ pub async fn post_edit(
};
let role = form.role.try_into()?;
if user.role == Role::AreaManager && (user.area_id != user_in_db.area_id || role == Role::Admin) {
if user.role == Role::AreaManager && (user.area_id != user_in_db.area_id || role == Role::Admin)
{
return Err(ApplicationError::Unauthorized);
}
let area_id = form.area.unwrap_or(user_in_db.area_id);
if Area::read_by_id(pool.get_ref(), area_id).await?.is_none() {
return Err(ApplicationError::Unauthorized);
}
let mut functions = Vec::with_capacity(3);
@ -53,14 +50,22 @@ pub async fn post_edit(
let changeset = UserChangeset {
name: form.name.clone(),
email: form.email.clone(),
email: form.email.to_lowercase(),
role,
functions,
area_id,
};
if let Err(e) = changeset.validate() {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
if let Some(existing_id) = User::exists(pool.get_ref(), &changeset.email).await? {
if existing_id != user_in_db.id {
return Ok(HttpResponse::UnprocessableEntity()
.body("email: an user already exists with the same email"));
}
}
let context = DbContext::new(pool.get_ref());
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};
User::update(pool.get_ref(), user_in_db.id, changeset).await?;
@ -145,4 +150,67 @@ mod tests {
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::BAD_REQUEST, response.status());
}
#[db_test]
async fn email_gets_cast_to_lowercase(context: &DbTestContext) {
User::create(&context.db_pool, Faker.fake()).await.unwrap();
Area::create(&context.db_pool, "Süd").await.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/users/edit/1").with_role(Role::Admin);
let new_name: String = Name().fake();
let new_mail: String = String::from("NONLowercaseEMAIL@example.com");
let form = NewOrEditUserForm {
name: new_name.clone(),
email: new_mail.clone(),
role: Role::AreaManager as u8,
is_posten: None,
is_wachhabender: None,
is_fuehrungsassistent: Some(true),
area: Some(2),
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::FOUND, response.status());
let updated_user = User::read_by_id(&context.db_pool, 1)
.await
.unwrap()
.unwrap();
assert_eq!(new_mail.to_lowercase(), updated_user.email);
}
#[db_test]
async fn fails_when_email_already_present(context: &DbTestContext) {
User::create(&context.db_pool, Faker.fake()).await.unwrap();
User::create(&context.db_pool, Faker.fake()).await.unwrap();
Area::create(&context.db_pool, "Süd").await.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/users/edit/1").with_role(Role::Admin);
let second_user = User::read_by_id(&context.db_pool, 2)
.await
.unwrap()
.unwrap();
let new_name: String = Name().fake();
let new_mail: String = second_user.email;
let form = NewOrEditUserForm {
name: new_name.clone(),
email: new_mail.clone(),
role: Role::AreaManager as u8,
is_posten: None,
is_wachhabender: None,
is_fuehrungsassistent: Some(true),
area: Some(2),
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::UNPROCESSABLE_ENTITY, response.status());
}
}

View File

@ -18,7 +18,7 @@ async fn post(
request: HttpRequest,
pool: web::Data<PgPool>,
) -> impl Responder {
if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email).await {
if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email.to_lowercase()).await {
let salt = user.salt.unwrap();
let hash = hash_plain_password_with_salt(&form.password, &salt).unwrap();

View File

@ -1,12 +1,11 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::user::NewOrEditUserForm,
mail::Mailer,
models::{Function, Registration, Role, User, UserChangeset},
utils::ApplicationError,
utils::{validation::{AsyncValidate, DbContext}, ApplicationError},
};
#[actix_web::post("/users/new")]
@ -47,14 +46,20 @@ pub async fn post_new(
let changeset = UserChangeset {
name: form.name.clone(),
email: form.email.clone(),
email: form.email.to_lowercase(),
role,
functions,
area_id,
};
if let Err(e) = changeset.validate() {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
if let Some(_) = User::exists(pool.get_ref(), &changeset.email).await? {
return Ok(HttpResponse::UnprocessableEntity()
.body("email: an user already exists with the same email"));
}
let context = DbContext::new(pool.get_ref());
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};
let id = User::create(pool.get_ref(), changeset).await?;

View File

@ -29,7 +29,9 @@ async fn post(
&& form.password.is_none()
&& form.passwordretyped.is_none()
{
if let Ok(user) = User::read_for_login(pool.get_ref(), form.email.as_ref().unwrap()).await {
if let Ok(user) =
User::read_for_login(pool.get_ref(), &form.email.as_ref().unwrap().to_lowercase()).await
{
let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id).await?;
mailer
.send_forgot_password_mail(&user, &reset.token)

View File

@ -24,7 +24,7 @@ ur noch ein Passwort festlegen. Kopiere daf=C3=BCr folgenden Link in deinen=
https://brasiwa-leipzig.de/register?token=3D123456789
Bitte beachte, dass der Link nur 24 Stunden g=C3=BCltig ist.
Bitte beachte, dass der Link nur 5 Tage lang g=C3=BCltig ist.
Viele Gr=C3=BC=C3=9Fe
--boundary
@ -41,7 +41,7 @@ nden Link in deinen Browser:</p>
<p>https://brasiwa-leipzig.de/register?token=3D123456789</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden g=C3=BCltig</b> ist.</p>
<p>Bitte beachte, dass der Link <b>nur 5 Tage lang g=C3=BCltig</b> ist.</p>
<p>Viele Gr=C3=BC=C3=9Fe</p>
--boundary--

View File

@ -52,6 +52,7 @@ async fn main() -> anyhow::Result<()> {
let mailer = Mailer::new(&config)?;
let customization = Customization {
webmaster_email: config.webmaster_email.clone(),
hostname: config.hostname.clone()
};
handle_command(args.command, &pool, &mailer).await?;

View File

@ -341,6 +341,49 @@ impl Availability {
Ok(availabilities)
}
pub async fn find_adjacent_by_time_for_user(
pool: &PgPool,
start: &NaiveDateTime,
end: &NaiveDateTime,
user: i32,
availability_to_ignore: Option<i32>,
) -> Result<Option<Availability>> {
let records = query!(
r##"
SELECT
availability.id,
availability.userId,
availability.startTimestamp,
availability.endTimestamp,
availability.comment
FROM availability
WHERE availability.userId = $1
AND (availability.endtimestamp = $2
OR availability.starttimestamp = $3)
AND (availability.id <> $4 OR $4 IS NULL);
"##,
user,
start.and_utc(),
end.and_utc(),
availability_to_ignore
)
.fetch_all(pool) // possible to find up to two availabilities (upper and lower), for now we only pick one and extend it
.await?;
let adjacent_avaialability = records.first().and_then(|r| {
Some(Availability {
id: r.id,
user_id: r.userid,
user: None,
start: r.starttimestamp.naive_utc(),
end: r.endtimestamp.naive_utc(),
comment: r.comment.clone(),
})
});
Ok(adjacent_avaialability)
}
pub async fn update(pool: &PgPool, id: i32, changeset: AvailabilityChangeset) -> Result<()> {
query!(
"UPDATE availability SET startTimestamp = $1, endTimestamp = $2, comment = $3 WHERE id = $4",

View File

@ -1,38 +1,63 @@
use chrono::{Days, NaiveDateTime};
use garde::Validate;
use sqlx::PgPool;
use crate::{END_OF_DAY, START_OF_DAY};
use crate::{
utils::validation::{
start_date_time_lies_before_end_date_time, AsyncValidate, AsyncValidateError,
},
END_OF_DAY, START_OF_DAY,
};
use super::{start_date_time_lies_before_end_date_time, Availability};
use super::Availability;
#[derive(Validate)]
#[garde(allow_unvalidated)]
#[garde(context(AvailabilityContext))]
pub struct AvailabilityChangeset {
#[garde(
custom(time_is_not_already_made_available),
custom(start_date_time_lies_before_end_date_time)
)]
pub time: (NaiveDateTime, NaiveDateTime),
pub comment: Option<String>,
}
pub struct AvailabilityContext {
pub existing_availabilities: Vec<Availability>,
pub struct AvailabilityContext<'a> {
pub pool: &'a PgPool,
pub user_id: i32,
pub availability_to_get_edited: Option<i32>,
}
impl<'a> AsyncValidate<'a> for AvailabilityChangeset {
type Context = AvailabilityContext<'a>;
async fn validate_with_context(
&self,
context: &'a Self::Context,
) -> Result<(), AsyncValidateError> {
let mut existing_availabilities =
Availability::read_by_user_and_date(context.pool, context.user_id, &self.time.0.date())
.await?;
if let Some(existing) = context.availability_to_get_edited {
existing_availabilities = existing_availabilities
.into_iter()
.filter(|a| a.id != existing)
.collect();
}
time_is_not_already_made_available(&self.time, &existing_availabilities)?;
start_date_time_lies_before_end_date_time(&self.time.0, &self.time.1)?;
Ok(())
}
}
fn time_is_not_already_made_available(
value: &(NaiveDateTime, NaiveDateTime),
context: &AvailabilityContext,
) -> garde::Result {
if context.existing_availabilities.is_empty() {
existing_availabilities: &Vec<Availability>,
) -> Result<(), AsyncValidateError> {
if existing_availabilities.is_empty() {
return Ok(());
}
let free_slots = find_free_date_time_slots(&context.existing_availabilities);
let free_slots = find_free_date_time_slots(existing_availabilities);
if free_slots.is_empty() {
return Err(garde::Error::new(
return Err(AsyncValidateError::new(
"cant create a availability as every time slot is already filled",
));
}
@ -41,7 +66,7 @@ fn time_is_not_already_made_available(
let free_block_found_for_end = free_slots.iter().any(|s| s.0 <= value.1 && s.1 >= value.1);
if !free_block_found_for_start || !free_block_found_for_end {
return Err(garde::Error::new(
return Err(AsyncValidateError::new(
"cant create availability as there exists already a availability with the desired time",
));
}

View File

@ -13,7 +13,7 @@ use super::{password_reset::Token, Result};
impl Registration {
pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<Registration> {
let (token, expires) = generate_token_and_expiration(64, TimeDelta::hours(24));
let (token, expires) = generate_token_and_expiration(64, TimeDelta::days(5));
let inserted = query_as!(
Registration,

View File

@ -147,6 +147,14 @@ impl User {
Ok(result)
}
pub async fn exists(pool: &PgPool, email: &str) -> Result<Option<i32>> {
let record = sqlx::query!("SELECT id FROM user_ WHERE email = $1;", email)
.fetch_optional(pool)
.await?;
Ok(record.and_then(|r| Some(r.id)))
}
pub async fn read_all(pool: &PgPool) -> anyhow::Result<Vec<User>> {
let records = sqlx::query!(
r#"

View File

@ -1,24 +1,44 @@
#[cfg(test)]
use fake::{faker::internet::en::SafeEmail, faker::name::en::Name, Dummy};
use garde::Validate;
use sqlx::PgPool;
use super::{Function, Role};
use crate::utils::validation::{email_is_valid, AsyncValidate, AsyncValidateError, DbContext};
#[derive(Debug, Validate)]
use super::{Area, Function, Role};
#[derive(Debug)]
#[cfg_attr(test, derive(Dummy))]
#[garde(allow_unvalidated)]
pub struct UserChangeset {
#[cfg_attr(test, dummy(faker = "Name()"))]
pub name: String,
#[garde(email)]
#[cfg_attr(test, dummy(faker = "SafeEmail()"))]
pub email: String,
#[cfg_attr(test, dummy(expr = "Role::Staff"))]
pub role: Role,
#[cfg_attr(test, dummy(expr = "vec![Function::Posten]"))]
pub functions: Vec<Function>,
/// check before: must exist and user can create other user for this area
#[cfg_attr(test, dummy(expr = "1"))]
pub area_id: i32,
}
impl <'a>AsyncValidate<'a> for UserChangeset {
type Context = DbContext<'a>;
async fn validate_with_context(&self, context: &'a Self::Context) -> Result<(), AsyncValidateError> {
email_is_valid(&self.email)?;
area_exists(context.pool, self.area_id).await?;
Ok(())
}
}
async fn area_exists(pool: &PgPool, id: i32) -> Result<(), AsyncValidateError> {
if Area::read_by_id(pool, id).await?.is_none() {
return Err(AsyncValidateError::new(
"Angegebener Bereich für Nutzer existiert nicht!",
));
}
Ok(())
}

View File

@ -1,4 +1,5 @@
#[derive(Clone)]
pub struct Customization {
pub hostname: String,
pub webmaster_email: String,
}

View File

@ -1,3 +1,4 @@
mod app_customization;
mod application_error;
pub mod auth;
mod date_time_format;
@ -5,16 +6,16 @@ pub mod event_planning_template;
pub mod manage_commands;
pub mod password_change;
mod template_response_trait;
mod app_customization;
pub mod token_generation;
pub mod validation;
#[cfg(test)]
pub mod test_helper;
pub use app_customization::Customization;
pub use application_error::ApplicationError;
pub use date_time_format::DateTimeFormat;
pub use template_response_trait::TemplateResponse;
pub use app_customization::Customization;
use chrono::{NaiveDate, Utc};

View File

@ -32,6 +32,7 @@ impl DbTestContext {
> {
let customization = Customization {
webmaster_email: self.config.webmaster_email.clone(),
hostname: self.config.hostname.clone()
};
init_service(create_app(

View File

@ -21,6 +21,7 @@ pub struct RequestConfig {
}
impl RequestConfig {
/// Creates a new [`RequestConfig`] with User as [`Role::Staff`] and [`Function::Posten`] in Area 1.
pub fn new(uri: &str) -> Self {
Self {
uri: uri.to_string(),

View File

@ -0,0 +1,58 @@
// great inspiration taken from https://github.com/jprochazk/garde/blob/main/garde/src/rules/email.rs
use regex::Regex;
use super::AsyncValidateError;
pub fn email_is_valid(email: &str) -> Result<(), AsyncValidateError> {
if email.is_empty() {
return Err(AsyncValidateError::new("E-Mail ist leer!"));
}
let (user, domain) = email
.split_once('@')
.ok_or(AsyncValidateError::new("E-Mail enthält kein '@'!"))?;
if user.len() > 64 {
return Err(AsyncValidateError::new("Nutzerteil der E-Mail zu lang!"));
}
let user_re = Regex::new(r"(?i-u)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+\z").unwrap();
if !user_re.is_match(user) {
return Err(AsyncValidateError::new(
"Nutzerteil der E-Mail enthält unerlaubte Zeichen.",
));
}
if domain.len() > 255 {
return Err(AsyncValidateError::new(
"Domainteil der E-Mail ist zu lang.",
));
}
let domain_re = Regex::new(
r"(?i-u)^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$",
)
.unwrap();
if !domain_re.is_match(domain) {
return Err(AsyncValidateError::new(
"Domainteil der E-Mail enthält unerlaubte Zeichen!",
));
}
Ok(())
}
#[test]
pub fn email_validation_works_correctly() {
assert!(email_is_valid("abc@example.com").is_ok());
assert!(email_is_valid("admin.new@example-domain.de").is_ok());
assert!(email_is_valid("admin!new@sub.web.www.example-domain.de").is_ok());
assert!(email_is_valid("admin.domain.de").is_err());
assert!(email_is_valid("admin@web@domain.de").is_err());
assert!(email_is_valid("@domain.de").is_err());
assert!(email_is_valid("user@").is_err());
assert!(email_is_valid("").is_err());
}

View File

@ -0,0 +1,29 @@
use tracing::error;
#[derive(Debug)]
pub struct AsyncValidateError {
message: String,
}
impl std::fmt::Display for AsyncValidateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for AsyncValidateError {}
impl AsyncValidateError {
pub fn new(message: &str) -> Self {
AsyncValidateError {
message: message.to_string(),
}
}
}
impl From<sqlx::Error> for AsyncValidateError {
fn from(value: sqlx::Error) -> Self {
error!(error = %value, "database error while validation input");
AsyncValidateError::new("Datenbankfehler beim Validieren!")
}
}

View File

@ -0,0 +1,32 @@
mod email;
mod error;
mod r#trait;
use chrono::NaiveDateTime;
pub use email::email_is_valid;
pub use error::AsyncValidateError;
pub use r#trait::AsyncValidate;
use sqlx::PgPool;
pub struct DbContext<'a> {
pub pool: &'a PgPool,
}
impl<'a> DbContext<'a> {
pub fn new(pool: &'a PgPool) -> Self {
Self { pool }
}
}
pub fn start_date_time_lies_before_end_date_time(
start: &NaiveDateTime,
end: &NaiveDateTime,
) -> Result<(), AsyncValidateError> {
if start >= end {
return Err(AsyncValidateError::new(
"endtime can't lie before starttime",
));
}
Ok(())
}

View File

@ -0,0 +1,8 @@
use super::AsyncValidateError;
pub trait AsyncValidate<'a> {
type Context: 'a;
async fn validate_with_context(&self, context: &'a Self::Context) -> Result<(), AsyncValidateError>;
}

View File

@ -10,13 +10,13 @@
"htmx.org": "^1.9.12",
"hyperscript.org": "^0.9.14",
"sass": "^1.77.8",
"sweetalert2-neutral": "^11.14.1-neutral-fix6"
"sweetalert2-neutral": "^11.18.0-neutral"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -78,9 +78,9 @@
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz",
"integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -99,25 +99,25 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.0",
"@parcel/watcher-darwin-arm64": "2.5.0",
"@parcel/watcher-darwin-x64": "2.5.0",
"@parcel/watcher-freebsd-x64": "2.5.0",
"@parcel/watcher-linux-arm-glibc": "2.5.0",
"@parcel/watcher-linux-arm-musl": "2.5.0",
"@parcel/watcher-linux-arm64-glibc": "2.5.0",
"@parcel/watcher-linux-arm64-musl": "2.5.0",
"@parcel/watcher-linux-x64-glibc": "2.5.0",
"@parcel/watcher-linux-x64-musl": "2.5.0",
"@parcel/watcher-win32-arm64": "2.5.0",
"@parcel/watcher-win32-ia32": "2.5.0",
"@parcel/watcher-win32-x64": "2.5.0"
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz",
"integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
@ -136,9 +136,9 @@
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz",
"integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
@ -157,9 +157,9 @@
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz",
"integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
@ -178,9 +178,9 @@
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz",
"integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
@ -199,9 +199,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz",
"integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
@ -220,9 +220,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz",
"integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
@ -241,9 +241,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz",
"integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
@ -262,9 +262,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz",
"integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
@ -283,9 +283,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz",
"integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
@ -304,9 +304,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz",
"integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
@ -325,9 +325,9 @@
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz",
"integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
@ -346,9 +346,9 @@
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz",
"integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
@ -367,9 +367,9 @@
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz",
"integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
@ -410,9 +410,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
@ -424,13 +424,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.1.tgz",
"integrity": "sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==",
"version": "24.0.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.1.tgz",
"integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.8"
"undici-types": "~7.8.0"
}
},
"node_modules/@webassemblyjs/ast": {
@ -609,9 +609,9 @@
"license": "Apache-2.0"
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
@ -622,30 +622,51 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^6.9.1"
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"ajv": "^8.8.2"
}
},
"node_modules/braces": {
@ -663,9 +684,9 @@
}
},
"node_modules/browserslist": {
"version": "4.24.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz",
"integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==",
"version": "4.25.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
"integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==",
"dev": true,
"funding": [
{
@ -683,10 +704,10 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001669",
"electron-to-chromium": "^1.5.41",
"node-releases": "^2.0.18",
"update-browserslist-db": "^1.1.1"
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3"
},
"bin": {
"browserslist": "cli.js"
@ -703,16 +724,16 @@
"license": "MIT"
},
"node_modules/bulma": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.2.tgz",
"integrity": "sha512-D7GnDuF6seb6HkcnRMM9E739QpEY9chDzzeFrHMyEns/EXyDJuQ0XA0KxbBl/B2NTsKSoDomW61jFGFaAxhK5A==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz",
"integrity": "sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g==",
"dev": true,
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001683",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001683.tgz",
"integrity": "sha512-iqmNnThZ0n70mNwvxpEC2nBJ037ZHZUoBI5Gorh1Mw6IlEAZujEoU1tXA628iZfzm7R9FvFzxbfdgml82a3k8Q==",
"version": "1.0.30001723",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001723.tgz",
"integrity": "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==",
"dev": true,
"funding": [
{
@ -731,9 +752,9 @@
"license": "CC-BY-4.0"
},
"node_modules/chokidar": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -771,9 +792,9 @@
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.39.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
"version": "3.43.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz",
"integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -797,16 +818,16 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.64",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.64.tgz",
"integrity": "sha512-IXEuxU+5ClW2IGEYFC2T7szbyVgehupCWQe5GNh+H065CD6U6IFN0s4KeAMFGNmQolRU4IV7zGBWSYMmZ8uuqQ==",
"version": "1.5.167",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.167.tgz",
"integrity": "sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==",
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
"integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==",
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -818,9 +839,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
@ -898,12 +919,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
"license": "MIT"
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/feather-icons": {
"version": "4.29.2",
@ -976,9 +1007,9 @@
}
},
"node_modules/immutable": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
"dev": true,
"license": "MIT"
},
@ -1041,9 +1072,9 @@
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
@ -1125,9 +1156,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true,
"license": "MIT"
},
@ -1152,16 +1183,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -1173,19 +1194,29 @@
}
},
"node_modules/readdirp": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16.0"
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -1208,9 +1239,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.81.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.81.0.tgz",
"integrity": "sha512-Q4fOxRfhmv3sqCLoGfvrC9pRV8btc0UtqL9mN6Yrv6Qi9ScL55CVH1vlPP863ISLEEMNLLuu9P+enCeGHlnzhA==",
"version": "1.89.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz",
"integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1229,15 +1260,16 @@
}
},
"node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.1.0"
},
"engines": {
"node": ">= 10.13.0"
@ -1305,9 +1337,9 @@
}
},
"node_modules/sweetalert2-neutral": {
"version": "11.14.1-neutral-fix6",
"resolved": "https://registry.npmjs.org/sweetalert2-neutral/-/sweetalert2-neutral-11.14.1-neutral-fix6.tgz",
"integrity": "sha512-DpBDnF43AKBtpUZ96uqLsMhezvAG87rXS4Af69e1MP7zfh/8nKPkDeAD3w8oTNl32FUqBcF2d0d0HmAEH2+viw==",
"version": "11.18.0-neutral",
"resolved": "https://registry.npmjs.org/sweetalert2-neutral/-/sweetalert2-neutral-11.18.0-neutral.tgz",
"integrity": "sha512-AF+hBm/V850oS/65raRtkQHM0MNe6HKKkrWFfzLLUhmA+3ISjU4IAq6eIBMvu7qM9PKjSTDpY0YS1KRpZM+0kw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1315,9 +1347,9 @@
}
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"dev": true,
"license": "MIT",
"engines": {
@ -1325,14 +1357,14 @@
}
},
"node_modules/terser": {
"version": "5.36.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz",
"integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==",
"version": "5.42.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.42.0.tgz",
"integrity": "sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
"acorn": "^8.14.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
@ -1344,17 +1376,17 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.10",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz",
"integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==",
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.20",
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
"schema-utils": "^3.1.1",
"serialize-javascript": "^6.0.1",
"terser": "^5.26.0"
"schema-utils": "^4.3.0",
"serialize-javascript": "^6.0.2",
"terser": "^5.31.1"
},
"engines": {
"node": ">= 10.13.0"
@ -1393,16 +1425,16 @@
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
"integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true,
"funding": [
{
@ -1421,7 +1453,7 @@
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.0"
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
@ -1430,20 +1462,10 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/watchpack": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
"integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==",
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1455,17 +1477,18 @@
}
},
"node_modules/webpack": {
"version": "5.96.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz",
"integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==",
"version": "5.99.9",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz",
"integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
"@webassemblyjs/ast": "^1.12.1",
"@webassemblyjs/wasm-edit": "^1.12.1",
"@webassemblyjs/wasm-parser": "^1.12.1",
"@types/json-schema": "^7.0.15",
"@webassemblyjs/ast": "^1.14.1",
"@webassemblyjs/wasm-edit": "^1.14.1",
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.14.0",
"browserslist": "^4.24.0",
"chrome-trace-event": "^1.0.2",
@ -1479,9 +1502,9 @@
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^3.2.0",
"schema-utils": "^4.3.2",
"tapable": "^2.1.1",
"terser-webpack-plugin": "^5.3.10",
"terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1",
"webpack-sources": "^3.2.3"
},
@ -1502,9 +1525,9 @@
}
},
"node_modules/webpack-sources": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.2.tgz",
"integrity": "sha512-ykKKus8lqlgXX/1WjudpIEjqsafjOTcOJqxnAbMLAu/KCsDCJ6GBtvscewvTkrn24HsnvFwrSCbenFrhtcCsAA==",
"dev": true,
"license": "MIT",
"engines": {

View File

@ -5,7 +5,7 @@
"htmx.org": "^1.9.12",
"hyperscript.org": "^0.9.14",
"sass": "^1.77.8",
"sweetalert2-neutral": "^11.14.1-neutral-fix6"
"sweetalert2-neutral": "^11.18.0-neutral"
},
"scripts": {
"build-bulma": "sass --load-path=node_modules --no-source-map style.scss dist/style.css"

View File

@ -44,6 +44,7 @@ $primary: $crimson,
@forward "bulma/sass/helpers/flexbox";
@forward "bulma/sass/helpers/color";
@forward "bulma/sass/helpers/visibility";
@forward "bulma/sass/helpers/other";
// Import the themes so that all CSS variables have a value
@forward "bulma/sass/themes";

View File

@ -9,7 +9,7 @@
date|fmt_date(DayMonthYear) }}</h1>
<input type="hidden" name="startdate" value="{{ date }}">
<input type="hidden" name="enddate" value="{{ date }}" id="enddate">
<input type="hidden" name="enddate" value="{{ enddate.as_ref().unwrap_or(date) }}" id="enddate">
<div class="field is-horizontal">
<div class="field-label">
@ -35,6 +35,7 @@
then if (value of the previous <input/>) is greater than (value of me)
then set the value of #enddate to "{{ datetomorrow }}"
then put "{{ datetomorrow|fmt_date(DayMonth) }}" into #ed
then set checked of #radionextday to true
end' />
</div>
</div>
@ -55,7 +56,7 @@
am selben Tag
</label>
<label class="radio ml-3">
<input type="radio" name="isovernight" {{ is_overnight|cond_show("checked")|safe }}
<input type="radio" id="radionextday" name="isovernight" {{ is_overnight|cond_show("checked")|safe }}
_='on click set the value of #enddate to "{{ datetomorrow }}"
then put "{{ datetomorrow|fmt_date(DayMonth) }}" into #ed'>
am Tag darauf
@ -80,7 +81,7 @@
<use href="/static/feather-sprite.svg#info" />
</svg>
{% let start_time = start.unwrap_or(NaiveTime::from_hms_opt(10, 0, 0).unwrap()) %}
{% let end_time = start.unwrap_or(NaiveTime::from_hms_opt(20, 0, 0).unwrap()) %}
{% let end_time = end.unwrap_or(NaiveTime::from_hms_opt(20, 0, 0).unwrap()) %}
verfügbar von {{ date|fmt_date(DayMonth) }} <span id="st">{{ start_time|fmt_time(HourMinute) }}</span> Uhr
bis <span id="ed">{{ enddate.as_ref().unwrap_or(date)|fmt_date(DayMonth) }}</span>
<span id="et">{{ end_time|fmt_time(HourMinute) }}</span> Uhr

View File

@ -4,6 +4,6 @@
<p>{{ register_url }}</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden gültig</b> ist.</p>
<p>Bitte beachte, dass der Link <b>nur 5 Tage lang gültig</b> ist.</p>
<p>Viele Grüße</p>

View File

@ -4,6 +4,6 @@ dein Account für https://{{ hostname }} wurde erstellt. Du musst nur noch ein P
{{ register_url }}
Bitte beachte, dass der Link nur 24 Stunden gültig ist.
Bitte beachte, dass der Link nur 5 Tage lang gültig ist.
Viele Grüße

View File

@ -11,20 +11,28 @@
<div class="field">
<label class="label">Zeitraum Start</label>
<div class="control">
<input class="input" type="date" name="start" required value="{{ daterange.0 }}" />
<input class="input" type="date" name="start" id="start" required value="{{ daterange.0 }}" />
</div>
<div class="tags help">
<span class="tag">aktueller Monat</span>
<span class="tag">nächster Monat</span>
<span class="tag">aktuelles Quartal</span>
<span class="tag">nächstes Quartal</span>
<span class="tag is-link is-light is-clickable"
_='on click set the value of #start to "{{ current_month.0 }}" then set the value of #end to "{{ current_month.1 }}"'>
aktueller Monat</span>
<span class="tag is-link is-light is-clickable"
_='on click set the value of #start to "{{ next_month.0 }}" then set the value of #end to "{{ next_month.1 }}"'>
nächster Monat</span>
<span class="tag is-link is-light is-clickable"
_='on click set the value of #start to "{{ current_quarter.0 }}" then set the value of #end to "{{ current_quarter.1 }}"'>
aktuelles Quartal</span>
<span class="tag is-link is-light is-clickable"
_='on click set the value of #start to "{{ next_quarter.0 }}" then set the value of #end to "{{ next_quarter.1 }}"'>
nächstes Quartal</span>
</div>
</div>
<div class="field">
<label class="label">Zeitraum Ende</label>
<div class="control">
<input class="input" type="date" name="end" required value="{{ daterange.1 }}" />
<input class="input" type="date" name="end" id="end" required value="{{ daterange.1 }}" />
</div>
</div>

View File

@ -54,7 +54,7 @@
</p>
<ul>
<li>Vor- und Nachname z.B. <code>Max Mustermann</code></li>
<li>E-Mail Adresse z.B. <code>max.mustermann@brasiwa-leipzig.de</code></li>
<li>E-Mail Adresse z.B. <code>max.mustermann@{{ hostname }}</code></li>
<li>Funktion für Brandsicherheitswachen z.B. <code>Wachhabender</code></li>
<li>Brandsicherheitswachbereich z.B. <code>Bereich Ost</code></li>
<li>Zeitpunkt deines letzten Logins z.B. <code>2021-06-11 09:40:47</code></li>
@ -72,6 +72,9 @@
</ul>
<h1>Copyright</h1>
<p>Copyright 2025 Max Hohlfeld</p>
<p>Brass ist freie Software nach <a href="https://www.gnu.org/licenses/agpl-3.0.en.html#license-text"
target="_blank">GNU AGPLv3</a> Lizenz.</p>
</section>
{% endblock %}

View File

@ -70,7 +70,7 @@
<div class="navbar-item">
angemeldet als {{ user.name }}
<div class="buttons ml-3">
<a class="button is-success" hx-boost="true" href="/profile">
<a class="button is-success is-outlined" hx-boost="true" href="/profile">
<svg class="icon">
<use href="/static/feather-sprite.svg#user" />
</svg>
@ -81,8 +81,11 @@
<use href="/static/feather-sprite.svg#moon" />
</svg>
</button>
<a href="/logout" class="button is-light">
Abmelden
<a href="/logout" class="button is-danger is-outlined">
<svg class="icon">
<use href="/static/feather-sprite.svg#log-out" />
</svg>
<span>Abmelden</span>
</a>
</div>
</div>

View File

@ -3,149 +3,147 @@
{% block content %}
<section class="section">
<div class="container">
{% if id.is_some() %}
<form method="post" action="/users/edit/{{ id.unwrap() }}">
<h1 class="title">Nutzer '{{ name.as_ref().unwrap() }}' bearbeiten</h1>
{% else %}
<form method="post" action="/users/new">
<h1 class="title">Neuen Nutzer anlegen</h1>
{% endif %}
<form hx-post="/users/{% if let Some(id) = id %}edit/{{ id }}{% else %}new{% endif %}" hx-target-422="find p">
<h1 class="title">
{% if let Some(name) = name %}Nutzer '{{ name }}' bearbeiten{% else %}Neuen Nutzer anlegen{% endif %}
</h1>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">E-Mail</label>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">E-Mail</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" type="text" name="email" placeholder="max.mustermann@brasiwa-leipzig.de" {{
email|insert_value|safe }} required _="on input put '' into the next <p/>" />
</div>
<p class="help is-danger"></p>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" type="text" name="email" placeholder="max.mustermann@brasiwa-leipzig.de" {{
email|insert_value|safe }} required />
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Name</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" type="text" name="name" placeholder="Max Mustermann" {{ name|insert_value|safe }}
required />
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Rolle</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="control">
<div class="select is-fullwidth">
<select name="role">
<option value="1" {{ role|is_some_and_eq(1|ref)|ref|cond_show("selected") }}>Personal</option>
<option value="10" {{ role|is_some_and_eq(10|ref)|ref|cond_show("selected") }}>Bereichsleiter
</option>
{% if user.role == Role::Admin %}
<option value="100" {{ role|is_some_and_eq(100|ref)|ref|cond_show("selected") }}>Admin</option>
{% endif %}
</select>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Name</label>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Funktion</label>
</div>
<div class="field-body">
<div class="field is-grouped">
<div class="control">
<label class="checkbox">
<input id="is-posten" type="checkbox" name="is-posten" value="true" {{
is_posten.unwrap_or(false)|ref|cond_show("checked") }} />
Posten
</label>
</div>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="is-wachhabender" value="true" {{
is_wachhabender.unwrap_or(false)|ref|cond_show("checked") }}
_="on change if me.checked then set checked of previous <input/> to true" />
Wachhabender
</label>
</div>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="is-fuehrungsassistent" value="true" {{
is_fuehrungsassistent.unwrap_or(false)|ref|cond_show("checked") }} />
Führungsassistent
</label>
</div>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" type="text" name="name" placeholder="Max Mustermann" {{ name|insert_value|safe }}
required />
</div>
</div>
{% if user.role == Role::Admin %}
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Bereich</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="control">
<div class="select is-fullwidth">
<select name="area">
{% for area in areas.as_ref().unwrap() %}
<option value="{{ area.id }}" {{ area_id|is_some_and_eq(area.id)|ref|cond_show("selected") }}>{{
area.name }}</input>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Rolle</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="control">
<div class="select is-fullwidth">
<select name="role">
<option value="1" {{ role|is_some_and_eq(1|ref)|ref|cond_show("selected") }}>Personal</option>
<option value="10" {{ role|is_some_and_eq(10|ref)|ref|cond_show("selected") }}>Bereichsleiter
</option>
{% if user.role == Role::Admin %}
<option value="100" {{ role|is_some_and_eq(100|ref)|ref|cond_show("selected") }}>Admin</option>
{% endif %}
</select>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label"></div>
<div class="field-body">
<div class="field is-grouped">
<div class="control">
<button class="button is-success">
<svg class="icon">
<use href="/static/feather-sprite.svg#check-circle" />
</svg>
<span>
{% if id.is_some() %}
Speichern
{% else %}
Erstellen
{% endif %}
</span>
</button>
</div>
<div class="control">
<a class="button is-link is-light" hx-boost="true" href="/users">
<svg class="icon">
<use href="/static/feather-sprite.svg#arrow-left" />
</svg>
<span>Zurück</span>
</a>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Funktion</label>
</div>
<div class="field-body">
<div class="field is-grouped">
<div class="control">
<label class="checkbox">
<input id="is-posten" type="checkbox" name="is-posten" value="true" {{
is_posten.unwrap_or(false)|ref|cond_show("checked") }} />
Posten
</label>
</div>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="is-wachhabender" value="true" {{
is_wachhabender.unwrap_or(false)|ref|cond_show("checked") }}
_="on change if me.checked then set checked of previous <input/> to true" />
Wachhabender
</label>
</div>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="is-fuehrungsassistent" value="true" {{
is_fuehrungsassistent.unwrap_or(false)|ref|cond_show("checked") }} />
Führungsassistent
</label>
</div>
</div>
</div>
</div>
{% if user.role == Role::Admin %}
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Bereich</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="control">
<div class="select is-fullwidth">
<select name="area">
{% for area in areas.as_ref().unwrap() %}
<option value="{{ area.id }}" {{ area_id|is_some_and_eq(area.id)|ref|cond_show("selected") }}>{{
area.name }}</input>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="field is-horizontal">
<div class="field-label"></div>
<div class="field-body">
<div class="field is-grouped">
<div class="control">
<button class="button is-success">
<svg class="icon">
<use href="/static/feather-sprite.svg#check-circle" />
</svg>
<span>
{% if id.is_some() %}
Speichern
{% else %}
Erstellen
{% endif %}
</span>
</button>
</div>
<div class="control">
<a class="button is-link is-light" hx-boost="true" href="/users">
<svg class="icon">
<use href="/static/feather-sprite.svg#arrow-left" />
</svg>
<span>Zurück</span>
</a>
</div>
</div>
</div>
</div>
</form>
</form>
</div>
</section>