Compare commits

..
14 Commits
Author SHA1 Message Date
pepaandClaude Opus 5.5 6c59c87f27 Deployment supports nginx and PostgreSQL installed on the host, added import of an existing installation.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:24:20 +02:00
pepaandClaude Opus 5.5 156fe1a0f4 Added health check and deployment setup for one container per customer. Fixes #12
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:34:40 +02:00
pepaandClaude Opus 5.5 c1d998878a Added .dockerignore, Docker image can be built on arm64. Refs #12
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:15:15 +02:00
pepaandClaude Opus 5.5 53c043a6b0 Made database connection pool size configurable. Fixes #11
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:47:36 +02:00
pepaandClaude Opus 5.5 e097d8ba51 Removed default admin password, the first admin account needs a configured one. Fixes #10
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:43:37 +02:00
pepaandClaude Opus 5.5 21b7007d28 Moved uploaded banner and generated styles to a persistent data directory. Fixes #9
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:25:57 +02:00
pepaandClaude Opus 5.5 cc21056ed3 Moved config file out of the publicly served directory. Fixes #8
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:05:12 +02:00
pepaandClaude Opus 5.5 ed72dc20f9 Added dark theme to admin portal. Fixes #7
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 11:07:29 +02:00
pepaandClaude Opus 5.5 9abf87c34d Fixed styling guidance in CLAUDE.md and frontend rules.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:45:50 +02:00
pepaandClaude Opus 5.5 1d5da2a6bf Fixed show password icon on login page. Fixes #6
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:00:54 +02:00
pepaandClaude Opus 5.5 ec73351405 Upgraded Leptos to 0.8. Fixes #1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 07:51:29 +02:00
pepaandClaude Opus 5.5 01d48ac0e4 Fixed parsing of opening hours with a discount. Fixes #4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:09:44 +02:00
pepaandClaude Opus 5.5 15ee091a0a Fixed missing separator between booking items in mail summary. Fixes #5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:19:33 +02:00
pepaandClaude Opus 5.5 11ace448ff Fixed opening hours validation accepting input the server cannot parse. Fixes #3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:15:41 +02:00
70 changed files with 3587 additions and 1192 deletions
+3
View File
@@ -0,0 +1,3 @@
# spow (proof of work for leptos-captcha) needs the getrandom backend selected for the browser
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
+2 -1
View File
@@ -11,7 +11,8 @@ paths:
## Server functions ## Server functions
- One file per domain in `src/backend/`. Server-only helpers and their imports go in the `cfg_if! { if #[cfg(feature = "ssr")] { ... }}` block at the top; `#[server]` functions follow below it. Extra server-only imports inside a server fn body are written as local `use` statements. - One file per domain in `src/backend/`. Server-only helpers and their imports go in the `cfg_if! { if #[cfg(feature = "ssr")] { ... }}` block at the top; `#[server]` functions follow below it. Extra server-only imports inside a server fn body are written as local `use` statements.
- Use plain `#[server]` for new functions (older ones spell out `#[server(Name, "/api", "Url", "name")]`; both end up under `/api`). - Use plain `#[server]` for new functions (a few older ones use `#[server(name = Name, prefix = "/api", endpoint = "name")]`; both end up under `/api`). The positional form with `"Url"` no longer compiles. Server functions are registered by `leptos_routes` in `src/main.rs`; there is no separate `/api` route.
- The current request is `use_context::<leptos_actix::Request>()` (it derefs to `HttpRequest`); extractors go through `leptos_actix::extract().await?`.
- Return `Result<ApiResponse<T>, ServerFnError>`. Expected/business failures are `Ok(ApiResponse::Error("English message".to_string()))` — the client passes that string through `trl()`, so add it to the catalogue. `Err(ServerFnError)` is for real failures (DB down etc.). - Return `Result<ApiResponse<T>, ServerFnError>`. Expected/business failures are `Ok(ApiResponse::Error("English message".to_string()))` — the client passes that string through `trl()`, so add it to the catalogue. `Err(ServerFnError)` is for real failures (DB down etc.).
- A function used with `<DataForm>` / `<QuestionDialog>` must return `ApiResponse<()>` and take the form entity as a single struct argument; its name is the prefix of the form field names (`company: Company` ↔ `name="company[name]"`). - A function used with `<DataForm>` / `<QuestionDialog>` must return `ApiResponse<()>` and take the form entity as a single struct argument; its name is the prefix of the form field names (`company: Company` ↔ `name="company[name]"`).
- For `<DataForm>`, implement `ForValidation` for the generated struct (PascalCase of the fn name) right below the function: - For `<DataForm>`, implement `ForValidation` for the generated struct (PascalCase of the fn name) right below the function:
+18 -5
View File
@@ -6,21 +6,32 @@ paths:
- "src/app.rs" - "src/app.rs"
- "src/validator.rs" - "src/validator.rs"
- "style/**" - "style/**"
- "assets/css/**"
--- ---
# Frontend rules # Frontend rules
## Components ## Components
- Leptos 0.6 API only. Components are `#[component]` functions returning `impl IntoView`, one page or dialog per file in `src/pages/`; reusable building blocks go to `src/components/`. Register new files in the respective `mod.rs` (`pub mod` only if used from `app.rs`). - Leptos 0.8 API only (`use leptos::prelude::*`). Components are `#[component]` functions returning `impl IntoView`, one page or dialog per file in `src/pages/`; reusable building blocks go to `src/components/`. Register new files in the respective `mod.rs` (`pub mod` only if used from `app.rs`).
- This code runs on the server (SSR) and in the browser (hydration). Browser-only APIs (`window()`, `web_sys`) must not run during SSR — keep them in event handlers/effects or behind `#[cfg(not(feature = "ssr"))]`. Markup must render the same on both sides or hydration breaks. - This code runs on the server (SSR) and in the browser (hydration). Browser-only APIs (`window()`, `web_sys`) must not run during SSR — keep them in event handlers/effects or behind `#[cfg(not(feature = "ssr"))]`. Markup must render the same on both sides or hydration breaks.
- A new admin page needs a `<Route path="admin/..."/>` in `src/app.rs` wrapped in `<AdminPortal>`, and a menu entry in `src/components/admin_portal.rs`. Paths starting with `/admin` are login-protected by the middleware; data access is still guarded by `perm_check!` in the server fn. - A new admin page needs a `<Route path="admin/..."/>` in `src/app.rs` wrapped in `<AdminPortal>`, and a menu entry in `src/components/admin_portal.rs`. Paths starting with `/admin` are login-protected by the middleware; data access is still guarded by `perm_check!` in the server fn.
## Leptos 0.8 pitfalls
- Views are statically typed: `if`/`else` and `match` arms that return different markup must each end with `.into_any()`.
- Context is not available in event handlers (`on:click` etc.). Call `use_context` in the component body and keep the result — `DialogOpener` and `MenuOpener` do this in `new()`, so create them in a component body, not inside a handler.
- Markup must be valid HTML, or hydration panics in the browser and the whole page stops reacting: no block elements (`<div>`, `<table>`, another `<p>`) inside `<p>`, and `<tr>` only inside `<tbody>`/`<thead>`. The compiler doesn't catch this — load the page with a full reload and check the browser console.
- HTML attributes are typed. Deprecated ones (`align`, `width` on `<table>`) don't compile; use `style`.
- A value used twice in one `view!` (e.g. in an attribute and as a child) must be cloned for the first use.
- Client-side form validation hooks into `<ActionForm>` with `on:submit:capture` and `ev.prevent_default()`; a plain `on:submit` runs too late. `DataForm` already does this.
- `action.value()` is not an `RwSignal`; pass it on as `Signal::derive(move || action.value().get())`.
## Data loading and forms ## Data loading and forms
- Load data with `create_blocking_resource` / `create_resource` calling the server fn, rendered inside `<Transition fallback=...>` with `{trl("Loading...")}`. Use a dialog's `opener.visible()` as the resource source so lists reload after the dialog closes (see `src/pages/users.rs`). - Load data with `Resource::new_blocking` / `Resource::new` calling the server fn, rendered inside `<Transition fallback=...>` with `{trl("Loading...")}`. Read the resource in a closure — `{move || res.get().map(...)}` — a plain `{res.get()...}` block is evaluated once and never updates. Use a dialog's `opener.visible()` as the resource source so lists reload after the dialog closes (see `src/pages/users.rs`).
- Handle all three outcomes of a server call: `Err(e)`, `Ok(ApiResponse::Error(msg))`, `Ok(ApiResponse::Data(d))`. - Handle all three outcomes of a server call: `Err(e)`, `Ok(ApiResponse::Error(msg))`, `Ok(ApiResponse::Data(d))`.
- Edit/create dialogs: `DialogOpener::new()` in the parent, `create_server_action::<ServerFnStruct>()` in the dialog, wrapped in `<DataForm opener=.. action=.. title="..">`. It supplies the modal, Save/Close buttons, client validation and server error display. Confirmations use `<QuestionDialog>`. - Edit/create dialogs: `DialogOpener::new()` in the parent, `ServerAction::<ServerFnStruct>::new()` in the dialog, wrapped in `<DataForm opener=.. action=.. title="..">`. It supplies the modal, Save/Close buttons, client validation and server error display. Confirmations use `<QuestionDialog>`.
- Form field names follow the server fn argument: `name="company[name]"`. Pass the id as a hidden input. Fill inputs with `prop:value={move || signal.get().field}`. - Form field names follow the server fn argument: `name="company[name]"`. Pass the id as a hidden input. Fill inputs with `prop:value={move || signal.get().field}`.
- `DataForm`/`ModalDialog` take `title` as an untranslated English key (`&'static str`); they translate it themselves. - `DataForm`/`ModalDialog` take `title` as an untranslated English key (`&'static str`); they translate it themselves.
@@ -34,6 +45,8 @@ paths:
## Styling ## Styling
- UI is the Bootstrap-based admin template in `assets/vendor/` plus Boxicons (`<i class="bx bx-...">`). Reuse its classes (`card`, `table`, `btn btn-primary`, `form-control`, `row`/`col`) before writing CSS. - UI is the Bootstrap-based admin template in `assets/vendor/` plus Boxicons (`<i class="bx bx-...">`). Reuse its classes (`card`, `table`, `btn btn-primary`, `form-control`, `row`/`col`) before writing CSS.
- Custom styles go to `style/main.scss`. Never edit `assets/vendor/`. - Custom styles go to a CSS file in `assets/css/` and must be linked with `<Link rel="stylesheet">` in `src/components/header.rs` (order matters: after the vendor files to override them). `style/main.scss` is not linked anywhere and has no effect. Never edit `assets/vendor/` (the one app-owned file there is `assets/vendor/css/control.css`).
- The public page banner style is `assets/data/banner.css`, rewritten at runtime by the appearance upload (`/*bg-img*/` marker) — keep that marker / the `background-image: url('...')` form intact. - The admin portal has a light and a dark theme (`src/components/theme.rs`, switch in the navbar: auto / light / dark, stored in the `theme` cookie). Dark colours are overrides in `assets/css/dark.css`, which `AdminPortal` links with a `media` attribute — the public page and the login page never load it. When you add or restyle something in the admin portal, check it in both themes and add the dark override there; don't hard-code light colours (`bg-light`, `bg-white`, inline `color`) in admin views.
- Charts are drawn on the server, so the chart server fns take a `dark` flag from `Theme::is_dark()`.
- The public page banner style template is `assets/banner.css`. On start it is copied to the data directory, and the appearance upload generates `banner-<random>.css` from it by replacing the `/*bg-img*/` marker — keep that marker intact.
- Stylesheets and meta tags are declared in `src/components/header.rs`. - Stylesheets and meta tags are declared in `src/components/header.rs`.
+23
View File
@@ -0,0 +1,23 @@
# Build output and VCS data are not needed in the image and are huge
/target/
/.git/
node_modules/
# Local configuration with passwords and runtime data must never get into an image layer
/config.toml
/data/
/assets/data/
/Maildir/
# Local tooling and scratch files
/.idea/
/.vscode/
/.settings/
/.project
/.claude/
/html/
/local_history.patch
/end2end/
# Deployment tooling and its secrets (customer configs, backups)
/deploy/
+4
View File
@@ -18,4 +18,8 @@ playwright/.cache/
/.settings/ /.settings/
/.vscode/ /.vscode/
/config.toml /config.toml
/data/
/html/ /html/
/deploy/.env
/deploy/customers/
/deploy/backups/
+15 -9
View File
@@ -1,6 +1,6 @@
# Rezervovator # Rezervovator
Booking system for sport/service facilities. Full-stack Rust: Leptos 0.6 (SSR + hydration) on Actix Web, PostgreSQL via sqlx. Crate name is `rezervator`, product name is "Rezervovator". Booking system for sport/service facilities. Full-stack Rust: Leptos 0.8 (SSR + hydration) on Actix Web, PostgreSQL via sqlx. Crate name is `rezervator`, product name is "Rezervovator".
One crate, compiled twice by cargo-leptos: One crate, compiled twice by cargo-leptos:
@@ -19,7 +19,7 @@ cargo check --lib --features hydrate --target wasm32-unknown-unknown # type-ch
cargo test --features ssr # unit tests cargo test --features ssr # unit tests
``` ```
Run both `cargo check` commands and the tests after any change; a plain `cargo check` / `cargo test` (no features) checks neither side properly. Run both `cargo check` commands and the tests after any change. They don't catch hydration errors, so after changing a view also load the page in a browser with a full reload (the browser caches the old WASM otherwise) and check the console; a plain `cargo check` / `cargo test` (no features) checks neither side properly.
Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[cfg(all(test, feature = "ssr"))]` for ssr-only code). They cover pure logic only — parsing, validation, paging, slot merging, mail templates, config. Nothing touches the database, so server functions, permissions and SQL are untested. Add tests for new pure logic; keep them free of DB and network. Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[cfg(all(test, feature = "ssr"))]` for ssr-only code). They cover pure logic only — parsing, validation, paging, slot merging, mail templates, config. Nothing touches the database, so server functions, permissions and SQL are untested. Add tests for new pure logic; keep them free of DB and network.
@@ -28,20 +28,24 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[
## Running locally ## Running locally
- `config.toml` (gitignored) is read from the working directory, or from `-c <path>`. Copy `config_sample.toml`. The bind address comes from `[network]` there, not from `LEPTOS_SITE_ADDR`. - `config.toml` (gitignored) is read from the working directory, or from `-c <path>`. Copy `config_sample.toml`. The bind address comes from `[network]` there, not from `LEPTOS_SITE_ADDR`.
- The config file holds secrets and must stay outside the site root (`target/site`), which is served as static files; the server refuses to start otherwise (`is_publicly_served` in `src/config.rs`). In Docker it is mounted to `/app/config/config.toml`.
- Migrations in `migrations/` are embedded by `sqlx::migrate!()` and run on startup. - Migrations in `migrations/` are embedded by `sqlx::migrate!()` and run on startup.
- First start seeds an `admin` / `admin` user, a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`). - First start creates the `admin` user with the password from `REZERVATOR_ADMIN_PASSWORD` or `[admin] initial_password` in the config (no default; the server refuses to start when no admin exists and no password is given). It also seeds a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`).
- With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent. - With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent.
- `assets/` is copied to `target/site` on build. `assets/data/` (banner.css, uploaded banner) is untracked but required: the page links `/data/<css_name>` and uploads are written to `target/site/data/` at runtime, relative to the working directory. A rebuild wipes `target/site`, so runtime uploads there are lost in dev. - `assets/` is copied to `target/site` on build and served as static files.
- Files created at runtime (uploaded banner, generated banner CSS) go to the data directory: `data/` in the working directory by default (gitignored), `[storage] data_dir` in the config, `/app/data` volume in Docker. It is served under `/data` and survives rebuilds. On start the server copies the `banner.css` template there from the site root and regenerates a missing banner style. Server code gets the path with `get_data_dir()`; never write into `target/site`.
## Layout ## Layout
- `src/main.rs` — Actix setup: config, pool, migrations, seeding, session + auth middleware, `/api/*` server fns, `/upload`. - `src/main.rs` — Actix setup: config, pool, migrations, seeding, session + auth middleware, `/api/*` server fns, `/upload`, `/health` (`src/backend/health.rs`).
- `src/app.rs` — `<App/>`, router, shared contexts (`MenuHelper`, `MenuOpener`, `DialogHelper`). - `src/app.rs` — `<App/>`, router, shared contexts (`MenuHelper`, `MenuOpener`, `DialogHelper`).
- `src/backend/` — server functions per domain, plus `data.rs` (all DTOs/entities shared by server and client) and `auth_middleware.rs` (redirects unauthenticated `/admin*` page requests to `/login`). - `src/backend/` — server functions per domain, plus `data.rs` (all DTOs/entities shared by server and client) and `auth_middleware.rs` (redirects unauthenticated `/admin*` page requests to `/login`).
- `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`). - `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`, `Theme` for the light/dark theme of the admin portal).
- `src/locales/` — `trl()` and the translation catalogue. - `src/locales/` — `trl()` and the translation catalogue.
- `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper. - `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper.
- `assets/vendor/` — third-party admin template (Bootstrap based). Don't edit or reformat it; app styles go to `style/main.scss`. - `deploy/` — running one container per customer on a Docker host: `rezervator.sh` (add, import of an existing installation, upgrade with rollback, backup, restore, remove), `docker-compose.yml` (optional Traefik and PostgreSQL containers), `README.md`. Proxy and PostgreSQL are each either a container or the one on the host (`REZERVATOR_PROXY=traefik|nginx`, `REZERVATOR_POSTGRES=container|host`); a change to the script must work in both. The script relies on the `/health` endpoint, the `REZERVATOR_ADMIN_PASSWORD` variable, the `/app/config` and `/app/data` mounts and the `max_connections` config key — keep them working when changing startup or config code, and test script changes against a real Docker stack with another `REZERVATOR_PREFIX`.
- `assets/vendor/` — third-party admin template (Bootstrap based). Don't edit or reformat it. App styles are plain CSS files in `assets/css/`, linked in `src/components/header.rs`.
- `style/main.scss` is compiled to `/pkg/rezervator.css`, but nothing links that file, so styles written there have no effect.
Routes: `/` public booking form, `/login`, `/admin`, `/admin/settings`, `/admin/mail_settings`, `/admin/bookings`, `/admin/customers`, `/admin/appearance`. Routes: `/` public booking form, `/login`, `/admin`, `/admin/settings`, `/admin/mail_settings`, `/admin/bookings`, `/admin/customers`, `/admin/appearance`.
@@ -54,8 +58,10 @@ Routes: `/` public booking form, `/login`, `/admin`, `/admin/settings`, `/admin/
- Code, comments, identifiers, log messages and commit messages are in English. UI strings are English keys translated at runtime (see `.claude/rules/frontend.md`). - Code, comments, identifiers, log messages and commit messages are in English. UI strings are English keys translated at runtime (see `.claude/rules/frontend.md`).
- Commit messages are always written in English, even when the conversation is in Czech: short past-tense sentences ending with a period, e.g. "Added setting for closing days." - Commit messages are always written in English, even when the conversation is in Czech: short past-tense sentences ending with a period, e.g. "Added setting for closing days."
- The project has no rustfmt config and the code is not rustfmt-formatted. Don't run `cargo fmt` over existing files; match the surrounding style and keep diffs limited to what you changed. - The project has no rustfmt config and the code is not rustfmt-formatted. Don't run `cargo fmt` over existing files; match the surrounding style and keep diffs limited to what you changed.
- Leptos is 0.6 — use the 0.6 API (`create_signal`, `create_resource`, `create_server_action`, `#[component]` returning `impl IntoView`), not 0.7/0.8 (`signal()`, `Resource::new`, …). - Leptos is 0.8 — use the 0.8 API (`use leptos::prelude::*`, `signal()`, `RwSignal::new`, `Resource::new_blocking`, `ServerAction::<T>::new()`), not the 0.6 `create_*` functions.
- `wasm-bindgen` is pinned to `=0.2.100`; it must match the wasm-bindgen version of the installed cargo-leptos. Don't bump it on its own. - `wasm-bindgen` is pinned to an exact version in `Cargo.toml`. The `wasm-bindgen` CLI that cargo-leptos runs must be exactly the same version (`cargo install -f wasm-bindgen-cli --version <same>`), otherwise the front-end build fails with a schema version mismatch. Leptos 0.8 needs at least 0.2.108. Change the pin and the CLI together, never one of them.
- `#![recursion_limit = "512"]` at the top of `src/lib.rs` and `src/main.rs` is required: without it the release build fails with "queries overflow the depth limit".
- `.cargo/config.toml` selects the `getrandom` backend for the WASM target (needed by the captcha's proof of work). `getrandom` and `uuid` have their `js` features enabled in `Cargo.toml` for the same reason; don't remove them.
- Version lives in `Cargo.toml` and is shown in the page footer. - Version lives in `Cargo.toml` and is shown in the page footer.
Detailed rules are in `.claude/rules/`: `backend.md`, `frontend.md`, `database.md`. Detailed rules are in `.claude/rules/`: `backend.md`, `frontend.md`, `database.md`.
Generated
+915 -683
View File
File diff suppressed because it is too large Load Diff
+16 -11
View File
@@ -13,20 +13,20 @@ actix-session = { version = "0.10.0", optional = true, features = ["cookie-sessi
actix-multipart = { version = "0.7.2", optional = true } actix-multipart = { version = "0.7.2", optional = true }
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
cfg-if = "1" cfg-if = "1"
leptos = { version = "0.6.13" } leptos = { version = "0.8.22" }
leptos_meta = { version = "0.6.13" } leptos_meta = { version = "0.8" }
leptos_actix = { version = "0.6.13", optional = true } leptos_actix = { version = "0.8", optional = true }
server_fn = { version = "0.6.13", features = ["multipart"] } server_fn = { version = "0.8" }
leptos_router = { version = "0.6.13" } leptos_router = { version = "0.8" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0.117" serde_json = "1.0.117"
wasm-bindgen = "=0.2.100" wasm-bindgen = "=0.2.129"
web-sys = { version = "0.3.69", features = ["Navigator"] } web-sys = { version = "0.3.69", features = ["Navigator"] }
lazy_static = "1.4.0" lazy_static = "1.4.0"
chrono = { version = "0.4.38", features = ["serde"]} chrono = { version = "0.4.38", features = ["serde"]}
sqlx = { version = "0.8.0", optional = true, features = ["runtime-tokio-rustls", "postgres", "chrono", "rust_decimal", "uuid"] } sqlx = { version = "0.8.0", optional = true, features = ["runtime-tokio-rustls", "postgres", "chrono", "rust_decimal", "uuid"] }
rust_decimal = "1.35.0" rust_decimal = "1.35.0"
uuid = {version = "1.8.0", features = ["v4", "serde"]} uuid = {version = "1.8.0", features = ["v4", "serde", "js"]}
validator = {version = "0.16.1", features = ["derive"]} validator = {version = "0.16.1", features = ["derive"]}
pwhash = "1.0.0" pwhash = "1.0.0"
futures-util = "0.3.30" futures-util = "0.3.30"
@@ -35,17 +35,20 @@ toml = "0.8.12"
log = "0.4.21" log = "0.4.21"
env_logger = "0.11.3" env_logger = "0.11.3"
getopts = "0.2.21" getopts = "0.2.21"
leptos-use = "0.13.13" leptos-use = "0.19.2"
codee = "0.3"
lettre = {version = "0.11.6", features = ["tokio1-native-tls", "smtp-transport", "file-transport"], optional = true} lettre = {version = "0.11.6", features = ["tokio1-native-tls", "smtp-transport", "file-transport"], optional = true}
leptos-captcha = "0.2.0" leptos-captcha = "0.4.0"
charts-rs = { version = "0.3.5", optional = true} charts-rs = { version = "0.3.5", optional = true}
#image = { version = "0.24.8", optional = true } #image = { version = "0.24.8", optional = true }
base64 = "0.22.0" base64 = "0.22.0"
rand = "0.8.5" rand = "0.8.5"
# rand needs the "js" backend when compiled for the browser
getrandom = { version = "0.2", features = ["js"] }
[features] [features]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"] csr = ["leptos/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] hydrate = ["leptos/hydrate"]
ssr = [ ssr = [
"dep:actix-files", "dep:actix-files",
"dep:actix-web", "dep:actix-web",
@@ -59,6 +62,8 @@ ssr = [
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",
"leptos-use/ssr",
"leptos-use/actix",
] ]
[profile.wasm-release] [profile.wasm-release]
+18 -5
View File
@@ -2,9 +2,10 @@ FROM rust:1.86.0-bookworm AS builder
# Install cargo-binstall, which makes it easier to install other # Install cargo-binstall, which makes it easier to install other
# cargo extensions like cargo-leptos # cargo extensions like cargo-leptos
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz # (uname -m gives x86_64 or aarch64, so the image builds on both architectures)
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-$(uname -m)-unknown-linux-musl.tgz \
RUN cp cargo-binstall /usr/local/cargo/bin && tar -xvf cargo-binstall-$(uname -m)-unknown-linux-musl.tgz \
&& cp cargo-binstall /usr/local/cargo/bin
# Install required tools # Install required tools
RUN apt-get update -y \ RUN apt-get update -y \
@@ -46,6 +47,18 @@ COPY --from=builder /app/target/site /app/target/site
# Set any required env variables and # Set any required env variables and
EXPOSE 3000 EXPOSE 3000
# -- NB: update binary name from "leptos_start" to match your app name in Cargo.toml -- # The config file holds passwords and the session key. Mount it to /app/config, never under
# /app/target/site, which is served as static files.
RUN mkdir -p /app/config
VOLUME /app/config
# Uploaded banner and generated styles. Keep this volume when the container is replaced.
RUN mkdir -p /app/data
VOLUME /app/data
# Healthy when the server answers and can reach its database
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --start-interval=2s --retries=3 \
CMD ["/app/rezervator", "-c", "/app/config/config.toml", "--healthcheck"]
# Run the server # Run the server
CMD ["/app/rezervator", "-c /app/target/site/data/config.toml"] CMD ["/app/rezervator", "-c", "/app/config/config.toml"]
+49
View File
@@ -13,10 +13,59 @@ You will need cargo-leptos tool:
`cargo install cargo-leptos` `cargo install cargo-leptos`
The `wasm-bindgen` command line tool must be exactly the same version as the `wasm-bindgen` dependency pinned in `Cargo.toml`:
`cargo install -f wasm-bindgen-cli --version 0.2.129`
## Running project ## Running project
`cargo leptos watch` `cargo leptos watch`
## Configuration
The server reads `config.toml` from the working directory, or from the path given with `-c`. Use `config_sample.toml` as a template.
The file contains the database and SMTP passwords and the session key. Keep it outside the site directory (`target/site`): everything in there is served as static files. The server refuses to start when the config file is inside it.
## Database connections
Each instance opens database connections on demand, up to `max_connections` in the `[database]` section of the config (10 by default), and closes the ones that stay idle. When many instances share one PostgreSQL server, the sum of their limits must stay below the server's own `max_connections` (100 by default): lower the limit per instance, raise it on the server, or put PgBouncer in front of PostgreSQL.
## First admin account
On the first start, when the database has no admin account, the server creates the user `admin`. Its password must be given, at least 8 characters, in one of these ways:
- the `REZERVATOR_ADMIN_PASSWORD` environment variable, or
- `initial_password` in the `[admin]` section of the config.
Without it the server refuses to start. Once an admin account exists the value is not used any more and should be removed.
Older versions created `admin` with the password `admin`. The server logs a warning on start while that password is still in use.
## Data directory
Files created while the application runs (the uploaded banner and the styles generated for it) are stored in the data directory and served under `/data`. By default it is `data` in the working directory; change it with `data_dir` in the `[storage]` section of the config. Keep this directory when you upgrade the application and include it in backups. Never put the config file into it.
When upgrading from a version that kept these files in `target/site/data`, move the banner image to the data directory. The style for it is generated again on start.
## Running in Docker
Mount the config file to `/app/config/config.toml` and a persistent volume for the data directory to `/app/data`:
`docker run -p 3000:3000 -v /path/to/config-dir:/app/config -v rezervator-data:/app/data rezervator`
Set `bind_ip = '0.0.0.0'` in the config so the server is reachable from outside the container.
Older versions of the image expected the config in `/app/target/site/data/config.toml`, where it could be downloaded from `/data/config.toml`. If you ran such a container, move the file and change the database password, the SMTP password and the session key.
## Health check
`GET /health` returns 200 when the server can reach its database and 503 otherwise. `rezervator --healthcheck` asks the running server on this machine and exits with 0 or 1; the Docker image uses it as its health check.
## Hosting many customers
The application serves one company per instance. `deploy/` contains a Docker Compose setup and a script for running one container per customer behind a reverse proxy, including upgrades, backups and restores. See [deploy/README.md](deploy/README.md).
## Running tests ## Running tests
`cargo test --features ssr` `cargo test --features ssr`
+249
View File
@@ -0,0 +1,249 @@
/*
* Dark theme of the admin portal.
*
* The file is linked only from the admin portal (src/components/admin_portal.rs) and switched
* on and off with the media attribute of the link, so the rules are not scoped by any class.
* It overrides colours of the vendor template for the components the admin portal uses.
******************************************************************************/
:root {
color-scheme: dark;
--dark-bg: #232333;
--dark-card: #2b2c40;
--dark-text: #a3a4cc;
--dark-heading: #cbcbe2;
--dark-muted: #7071a4;
--dark-border: #444564;
--dark-hover: rgba(255, 255, 255, 0.06);
--bs-body-bg: var(--dark-bg);
--bs-body-bg-rgb: 35, 35, 51;
--bs-body-color: var(--dark-text);
--bs-body-color-rgb: 163, 164, 204;
}
/* Typography */
h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6 {
color: var(--dark-heading);
}
hr {
color: var(--dark-border);
}
.text-muted {
color: var(--dark-muted) !important;
}
/* Layout: side menu, navbar, footer */
.bg-menu-theme,
.bg-navbar-theme {
background-color: var(--dark-card) !important;
color: var(--dark-text);
}
.bg-footer-theme {
background-color: var(--dark-bg) !important;
color: var(--dark-text);
}
.bg-menu-theme .menu-link,
.bg-menu-theme .menu-horizontal-prev,
.bg-menu-theme .menu-horizontal-next,
.bg-navbar-theme .navbar-nav > .nav-link,
.bg-navbar-theme .navbar-nav > .nav-item > .nav-link,
.bg-navbar-theme .navbar-nav > .nav > .nav-item > .nav-link {
color: var(--dark-text);
}
.bg-menu-theme .menu-link:hover,
.bg-menu-theme .menu-link:focus,
.bg-navbar-theme .navbar-nav > .nav-link:hover,
.bg-navbar-theme .navbar-nav > .nav-item > .nav-link:hover,
.bg-navbar-theme .navbar-nav > .nav-item > .nav-link:focus {
color: var(--dark-heading);
}
.bg-menu-theme .menu-inner > .menu-item:not(.active) > .menu-link:hover {
background-color: var(--dark-hover);
}
.layout-navbar,
.layout-navbar h4 {
color: var(--dark-heading);
}
.layout-menu,
.layout-navbar.navbar-detached {
box-shadow: 0 0 0.375rem 0.25rem rgba(0, 0, 0, 0.2);
}
.layout-overlay {
background: #000;
}
/* Year and month bar on the booking overview */
.filter-bar.bg-light {
background-color: var(--dark-card) !important;
border-radius: 0.5rem;
}
.filter-bar .nav-link {
color: var(--dark-text);
}
/* Cards */
.card {
background-color: var(--dark-card);
border-color: var(--dark-border);
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2);
}
.card-link.disabled,
a.disabled {
color: var(--dark-border);
}
/* Tables */
.table {
--bs-table-striped-color: var(--dark-text);
--bs-table-striped-bg: rgba(255, 255, 255, 0.03);
--bs-table-active-color: var(--dark-text);
--bs-table-active-bg: rgba(255, 255, 255, 0.08);
--bs-table-hover-color: var(--dark-text);
--bs-table-hover-bg: var(--dark-hover);
color: var(--dark-text);
border-color: var(--dark-border);
}
.table:not(.table-dark) th {
color: var(--dark-heading);
}
.table > :not(:first-child) {
border-top-color: var(--dark-border);
}
/* Forms */
.form-label,
.col-form-label,
.form-check-label {
color: var(--dark-heading);
}
.form-control,
.form-select,
.input-group-text {
color: var(--dark-heading);
background-color: var(--dark-card);
border-color: var(--dark-border);
}
.form-control::placeholder {
color: var(--dark-muted);
}
.form-control:focus,
.form-select:focus {
color: var(--dark-heading);
background-color: var(--dark-card);
border-color: #696cff;
}
.form-control:disabled,
.form-control[readonly],
.form-select:disabled {
background-color: var(--dark-bg);
}
.form-control[type="file"]::file-selector-button {
color: var(--dark-heading);
background-color: var(--dark-bg);
}
.form-control[type="file"]:hover:not(:disabled):not([readonly])::file-selector-button {
background-color: var(--dark-border);
}
.form-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23a3a4cc' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
}
.form-check-input:not(:checked) {
background-color: var(--dark-card);
border-color: var(--dark-border);
}
/* Buttons */
.btn-outline-secondary {
color: var(--dark-text);
border-color: var(--dark-muted);
}
.btn-outline-secondary:hover {
color: var(--dark-heading);
background-color: var(--dark-hover);
border-color: var(--dark-text);
}
.btn.p-0,
.btn:not([class*="btn-"]) {
color: var(--dark-text);
}
/* Dropdown menus */
.dropdown-menu {
color: var(--dark-text);
background-color: var(--dark-card);
border-color: var(--dark-border);
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
}
.dropdown-item {
color: var(--dark-text);
}
.dropdown-item:hover,
.dropdown-item:focus {
color: var(--dark-heading);
background-color: var(--dark-hover);
}
.dropdown-divider {
border-top-color: var(--dark-border);
}
/* Dialogs */
.modal-content {
background-color: var(--dark-card);
box-shadow: 0 0.25rem 1.5rem rgba(0, 0, 0, 0.5);
}
.modal .btn-close {
background-color: var(--dark-border);
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.4);
}
/* Alerts */
.alert-danger {
background-color: #55303a;
border-color: #6b3540;
color: #ff8a75;
}
/* Public booking form embedded in the "Create booking" dialog */
.leptos-captcha {
color: var(--dark-text);
}
+14
View File
@@ -11,6 +11,9 @@ password = 'password'
db_name = 'rezervator' db_name = 'rezervator'
host = 'localhost' host = 'localhost'
port = 5432 port = 5432
# Upper limit of open database connections, default is 10. Lower it when many instances
# share one PostgreSQL server.
#max_connections = 10
[mailing] [mailing]
transport = "File" #or Smtp transport = "File" #or Smtp
@@ -24,5 +27,16 @@ path = "Maildir"
#tls = true #tls = true
#accept_all_certs = true #accept_all_certs = true
# Password of the "admin" account, which is created on the first start when no admin exists.
# At least 8 characters. Can be given in the REZERVATOR_ADMIN_PASSWORD environment variable instead.
# It is not used once an admin account exists; remove it then.
#[admin]
#initial_password = ""
# Directory for uploaded files (banner) and generated styles, served under /data.
# Default is "data" in the working directory. Must survive application upgrades.
#[storage]
#data_dir = "data"
[logging] [logging]
severity = "debug" severity = "debug"
+45
View File
@@ -0,0 +1,45 @@
# Settings of the stack. Copy to .env (./rezervator.sh init does it and generates the password).
# Prefix of container, network and volume names. Change it to run a second stack on the same host.
REZERVATOR_PREFIX=rezervator
# Image the customer containers are started from
REZERVATOR_IMAGE=rezervator:latest
# Reverse proxy in front of the customer containers:
# traefik runs as a container of this stack, routes by domain, can issue certificates itself
# nginx the nginx already installed on this host. Every customer container listens on
# 127.0.0.1 on its own port (from BASE_PORT up) and the script generates an nginx
# server block for it. TLS is up to nginx; the settings below REZERVATOR_TLS are ignored.
REZERVATOR_PROXY=traefik
BASE_PORT=3100
# PostgreSQL server holding the customer databases:
# container runs as a container of this stack
# host the PostgreSQL already installed on this host. The script manages databases
# with "PG_ADMIN psql" and the containers connect to PG_HOST:PG_PORT.
REZERVATOR_POSTGRES=container
PG_ADMIN="sudo -u postgres"
PG_HOST=host.docker.internal
PG_PORT=5432
# Only for REZERVATOR_PROXY=traefik:
# true: HTTPS with Let's Encrypt certificates, HTTP is redirected. Needs public DNS records
# pointing to this host and ACME_EMAIL.
# false: plain HTTP only, for local testing or behind another TLS terminating proxy.
REZERVATOR_TLS=true
ACME_EMAIL=
# Ports published by Traefik
HTTP_PORT=80
HTTPS_PORT=443
# Only for REZERVATOR_POSTGRES=container: password of the PostgreSQL superuser.
# Generated by ./rezervator.sh init.
POSTGRES_PASSWORD=
# Connection limits. max_connections of PostgreSQL must be above
# (number of customers) x CUSTOMER_MAX_CONNECTIONS, or put PgBouncer in front of PostgreSQL.
# PG_MAX_CONNECTIONS sets it for REZERVATOR_POSTGRES=container; on the host, set it in postgresql.conf.
PG_MAX_CONNECTIONS=300
CUSTOMER_MAX_CONNECTIONS=5
+159
View File
@@ -0,0 +1,159 @@
# Running Rezervovator for many customers
The application serves one company per instance. To host many customers, every customer gets their own container from the same image, with their own database, configuration and data volume. This directory contains what is needed to run that on one Docker host:
- `rezervator.sh` — creates, imports, upgrades, backs up, restores and removes customers
- `docker-compose.yml` — the optional shared services: a reverse proxy (Traefik) and a PostgreSQL server
- `config.template.toml` — template of a customer's configuration
- `.env.example` — settings of the stack
The reverse proxy and PostgreSQL can each be either a container of this stack or the one already installed on the host:
| `.env` | Value | Meaning |
|---|---|---|
| `REZERVATOR_PROXY` | `traefik` (default) | Traefik container, routes by domain, issues Let's Encrypt certificates itself |
| | `nginx` | nginx installed on the host; the script generates a server block per customer |
| `REZERVATOR_POSTGRES` | `container` (default) | PostgreSQL container of this stack |
| | `host` | PostgreSQL installed on the host |
The two settings are independent. [Setup with nginx and PostgreSQL on the host](#setup-with-nginx-and-postgresql-on-the-host) below describes the combination for a server that already runs both.
## Requirements
- Docker with the Compose plugin, `bash` and `openssl` on the host
- The application image on the host: `docker build -t rezervator:latest .` in the repository root, or pulled from a registry. The image must match the CPU architecture of the host.
- A DNS record for every customer domain pointing to the host
## First setup
```sh
./rezervator.sh init # creates .env and stops
$EDITOR .env # proxy, PostgreSQL, image
$EDITOR config.template.toml # the [mailing] section: your SMTP server
./rezervator.sh init # starts the shared services, if any, and checks the setup
```
With `REZERVATOR_PROXY=traefik`, `REZERVATOR_TLS=true` gets a Let's Encrypt certificate for every customer domain and redirects HTTP to HTTPS; it needs `ACME_EMAIL` and ports 80 and 443 reachable from the internet. Use `false` only for local testing or behind another proxy that terminates TLS.
The session cookie is marked `Secure`. Whatever the setup, customers must reach the application over HTTPS, otherwise logging in works only on `localhost` addresses.
## Setup with nginx and PostgreSQL on the host
```ini
# .env
REZERVATOR_PROXY=nginx
REZERVATOR_POSTGRES=host
PG_ADMIN="sudo -u postgres"
```
Nothing from `docker-compose.yml` runs in this setup; `init` only creates the Docker network and checks that it can manage PostgreSQL and that containers can reach it.
Run the script as a user who may use Docker and `sudo -u postgres`.
### PostgreSQL
The script manages databases with `sudo -u postgres psql` and `pg_dump`. The customer containers connect over the Docker network to `host.docker.internal:5432`, which is the host's address on the Docker bridge, so PostgreSQL has to accept connections from there. A default installation listens on `localhost` only.
1. `postgresql.conf`: listen on the Docker bridge too. `listen_addresses = '*'` is the simple way; the next two steps keep it closed to everybody else. Check `max_connections` (see [Sizing](#sizing)).
2. `pg_hba.conf`: allow the Docker networks with password authentication. Every customer has a database and a role of the same name, so `sameuser` limits each role to its own database:
```text
host sameuser all 172.16.0.0/12 scram-sha-256
```
3. Firewall: allow port 5432 from `172.16.0.0/12` and keep it closed from outside.
4. Restart PostgreSQL and run `./rezervator.sh init`. It fails with a message when the containers cannot reach the database.
`172.16.0.0/12` covers Docker's default address pools; adjust it if your Docker uses other ranges. These steps were not tried on a Linux server yet — the script was tested against a PostgreSQL outside the stack reached through the host address, on Docker Desktop.
### nginx
Every customer container listens on `127.0.0.1` on its own port (`BASE_PORT` and up), not reachable from outside. `add` writes `customers/<name>/nginx.conf`, a plain HTTP server block that proxies the domain to that port; the commands to install it and to add a certificate with certbot are at the top of the file. Adapt it to how TLS is done on your server.
nginx's default limit for uploads is 1 MB; the generated block raises it to 20 MB for banner images.
### Moving an existing installation into a container
`import` creates a customer from an installation that runs directly on the host: it copies its database into a new one and the uploaded files from its data directory into a new volume. The accounts and passwords come along. The source is only read.
```sh
./rezervator.sh import main rezervace.example.com rezervator /srv/rezervator/target/site/data
```
The arguments are the new customer name, its domain, the name of the existing database and the directory with the uploaded banner (in old versions `target/site/data` next to the binary; leave it out if there is no banner). Config files in that directory are not copied.
Suggested order:
1. Copy the `[mailing]` section of the old `config.toml` into `config.template.toml`.
2. Try it: run `import`, check the new instance on its local port (`./rezervator.sh list` shows it), then `./rezervator.sh remove main --purge`.
3. For real: stop the old installation, run `import` again, and point the existing nginx server block of the domain to the new port (`proxy_pass http://127.0.0.1:<port>;`) — keeping its TLS settings is easier than installing the generated file. Reload nginx.
4. Keep the old database until the new instance has proven itself, then drop it.
Bookings made in the old installation between the import and the switch are not in the new one, hence stopping it first.
The new instance gets a new session key and a new database password, so everybody has to log in again. If the old installation kept its `config.toml` in `target/site/data`, that file was publicly downloadable: change the SMTP password too. `import` warns when the account `admin` still has the password `admin`.
## Adding a customer
```sh
./rezervator.sh add alpha rezervace.alpha.example
```
This creates the database and its role, writes `customers/alpha/config.toml` with a unique session key and database password, creates the data volume, starts the container and waits until it is healthy. At the end it prints the generated password of the `admin` account — it is shown only once.
An optional third argument sets the sender address of e-mails (default `noreply@<domain>`).
`./rezervator.sh list` shows all customers, their ports and the state of their containers.
## Upgrading
Build or pull the new image under the name set in `REZERVATOR_IMAGE`, then:
```sh
./rezervator.sh upgrade alpha # try one customer first
./rezervator.sh upgrade --all
```
Customers are upgraded one by one. For each one the script makes a backup, replaces the container (database migrations run when the new version starts) and waits for it to become healthy. Customers that already run the image are skipped, so an interrupted upgrade can simply be run again.
If a customer does not become healthy, the script puts back the database and data from the backup it just made, starts the previous image again and stops. The remaining customers are not touched. A customer is unavailable for a few seconds while its container is replaced.
## Backup and restore
```sh
./rezervator.sh backup --all
./rezervator.sh restore alpha 20260101T020000Z
```
A backup is two files in `backups/<name>/`: `<timestamp>-db.sql.gz` (database dump) and `<timestamp>-data.tar.gz` (uploaded banner and generated styles). `restore` replaces both the database and the data volume, and keeps the image the customer was running.
The script does not schedule backups, does not delete old ones and does not copy them off the host. Run `backup --all` from cron, and ship `backups/`, `customers/` and `.env` somewhere else — `customers/` and `.env` hold the passwords and session keys and cannot be regenerated.
## Removing a customer
```sh
./rezervator.sh remove alpha # stops the container, keeps everything else
./rezervator.sh remove alpha --purge # also deletes the database, data volume and config
```
After a plain `remove`, `upgrade alpha` starts the customer again. `--purge` asks for the customer name as confirmation and keeps existing backups. With nginx, remove the customer's server block yourself.
## Health and monitoring
Every instance answers `GET /health` with 200 when it can reach its database and with 503 otherwise. The image uses it as its Docker health check; `list` shows the result, and Traefik sends traffic only to healthy containers. Point an external monitor to `https://<domain>/health`.
## Sizing
- One idle instance used about 7 MiB of memory in a test, about 12 MiB after some use. PostgreSQL needs far more than that.
- Every instance may open up to `CUSTOMER_MAX_CONNECTIONS` database connections (5 by default; written into the customer's config when the customer is created) and closes idle ones. `max_connections` of PostgreSQL (100 in a default installation, `PG_MAX_CONNECTIONS` for the container) must stay above the number of customers times that limit, plus what else uses the server. Beyond that, put PgBouncer in front of PostgreSQL.
## Security notes
- `customers/` and `.env` contain secrets and are readable only by the user who ran the script. Keep them out of version control (they are in `.gitignore`).
- Customers share one PostgreSQL server but each has its own database and role, without access to the others.
- With Traefik: the proxy has read access to the Docker socket to discover containers, which is equivalent to root on the host. Do not expose its API or dashboard.
- With PostgreSQL on the host: the script runs `psql` as the PostgreSQL superuser, so whoever may run it controls all databases on that server, not only the customers'.
## Running a second stack
Change `REZERVATOR_PREFIX` (and the ports) in `.env` of another copy of this directory. All container, network and volume names are derived from the prefix.
+31
View File
@@ -0,0 +1,31 @@
# Template of the configuration of one customer instance. ./rezervator.sh add fills in the
# @PLACEHOLDERS@ and writes the result to customers/<name>/config.toml.
# Edit the [mailing] section before adding the first customer.
[session]
key = '@SESSION_KEY@'
[network]
bind_ip = '0.0.0.0'
port = 3000
[database]
user = '@DB_USER@'
password = '@DB_PASSWORD@'
db_name = '@DB_NAME@'
host = '@DB_HOST@'
port = @DB_PORT@
max_connections = @MAX_CONNECTIONS@
[mailing]
transport = "Smtp"
from = "@MAIL_FROM@"
server = "smtp.example.com"
#hello_name = "host.example.com"
port = 587
user = "smtp-user"
password = "smtp-password"
tls = true
[logging]
severity = "info"
+65
View File
@@ -0,0 +1,65 @@
# Shared infrastructure for running one Rezervovator container per customer:
# the reverse proxy and the PostgreSQL server. Customer containers are not listed
# here, they are created with ./rezervator.sh (see README.md in this directory).
#
# Both services are optional and selected by profiles: ./rezervator.sh init starts
# "traefik" unless REZERVATOR_PROXY=nginx, and "postgres" unless REZERVATOR_POSTGRES=host.
name: ${REZERVATOR_PREFIX:-rezervator}
services:
proxy:
profiles: ["traefik"]
image: traefik:v3
container_name: ${REZERVATOR_PREFIX:-rezervator}-proxy
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=${REZERVATOR_PREFIX:-rezervator}
# Only containers of this stack, so two stacks on one host don't mix
- --providers.docker.constraints=Label(`rezervator.stack`,`${REZERVATOR_PREFIX:-rezervator}`)
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
# Let's Encrypt certificates, used when REZERVATOR_TLS=true
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:-}
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- rezervator
postgres:
profiles: ["postgres"]
image: postgres:17
container_name: ${REZERVATOR_PREFIX:-rezervator}-postgres
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
# Every customer instance may open up to CUSTOMER_MAX_CONNECTIONS connections
command: ["postgres", "-c", "max_connections=${PG_MAX_CONNECTIONS:-300}"]
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"]
interval: 5s
timeout: 3s
retries: 20
networks:
- rezervator
# Created by ./rezervator.sh init, because customer containers use it even when
# neither service above runs
networks:
rezervator:
name: ${REZERVATOR_PREFIX:-rezervator}
external: true
volumes:
letsencrypt:
postgres-data:
+669
View File
@@ -0,0 +1,669 @@
#!/usr/bin/env bash
#
# Management of a Rezervovator stack with one container per customer.
# See README.md in this directory.
set -euo pipefail
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$DEPLOY_DIR/.env"
CUSTOMERS_DIR="$DEPLOY_DIR/customers"
BACKUPS_DIR="$DEPLOY_DIR/backups"
usage() {
cat <<USAGE
Usage: $(basename "$0") <command> [arguments]
init Create .env; then start the shared services and check the setup
add <name> <domain> [from] Create a customer: database, config, volume, container.
[from] is the sender address of e-mails, default noreply@<domain>
import <name> <domain> <database> [data-dir] [from]
Create a customer from an existing installation: copies its
database and the files from its data directory. The source is
only read.
list List customers and the state of their containers
upgrade <name>...|--all Recreate containers from REZERVATOR_IMAGE, one by one.
Backs up first, rolls back and stops when one fails.
backup <name>...|--all Dump the database and the data volume to backups/<name>/
restore <name> <timestamp> Restore a customer from backups/<name>/<timestamp>-*
remove <name> [--purge] Stop and remove the container. With --purge also the database,
the data volume and the config (asks for confirmation).
<name> is lowercase letters, digits and dashes, starting with a letter.
USAGE
}
die() {
echo "Error: $*" >&2
exit 1
}
info() {
echo "==> $*"
}
random_string() {
# $1 = number of bytes, output is twice as many hex characters
openssl rand -hex "$1"
}
load_env() {
[ -f "$ENV_FILE" ] || die "$ENV_FILE not found, run '$(basename "$0") init' first"
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
PREFIX="${REZERVATOR_PREFIX:-rezervator}"
IMAGE="${REZERVATOR_IMAGE:-rezervator:latest}"
TLS="${REZERVATOR_TLS:-true}"
PROXY="${REZERVATOR_PROXY:-traefik}"
POSTGRES="${REZERVATOR_POSTGRES:-container}"
PG_CONTAINER="$PREFIX-postgres"
PG_PORT="${PG_PORT:-5432}"
NETWORK="$PREFIX"
CUSTOMER_MAX_CONNECTIONS="${CUSTOMER_MAX_CONNECTIONS:-5}"
BASE_PORT="${BASE_PORT:-3100}"
case "$PROXY" in traefik|nginx) ;; *) die "REZERVATOR_PROXY must be traefik or nginx" ;; esac
case "$POSTGRES" in container|host) ;; *) die "REZERVATOR_POSTGRES must be container or host" ;; esac
if [ "$POSTGRES" = "host" ]; then
# Address of the Docker host as the containers see it
DB_HOST="${PG_HOST:-host.docker.internal}"
# How to run psql and pg_dump as the PostgreSQL superuser; may be empty
PG_ADMIN="${PG_ADMIN-sudo -u postgres}"
else
DB_HOST="$PG_CONTAINER"
fi
}
compose() {
docker compose --project-directory "$DEPLOY_DIR" --env-file "$ENV_FILE" "$@"
}
# Starts the shared services that run as containers in this setup, if any
compose_up() {
local profiles=()
if [ "$PROXY" = "traefik" ]; then profiles+=(--profile traefik); fi
if [ "$POSTGRES" = "container" ]; then profiles+=(--profile postgres); fi
docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" >/dev/null
if [ "${#profiles[@]}" -gt 0 ]; then
compose "${profiles[@]}" up --detach
fi
}
check_name() {
[[ "$1" =~ ^[a-z][a-z0-9-]{0,30}$ ]] || die "Invalid customer name '$1'"
}
container_name() { echo "$PREFIX-app-$1"; }
volume_name() { echo "$PREFIX-data-$1"; }
# Database and role have the same name
db_name() { echo "rz_${1//-/_}"; }
customer_exists() {
[ -f "$CUSTOMERS_DIR/$1/customer.env" ]
}
require_customer() {
check_name "$1"
customer_exists "$1" || die "Unknown customer '$1'"
}
all_customers() {
[ -d "$CUSTOMERS_DIR" ] || return 0
for dir in "$CUSTOMERS_DIR"/*/; do
if [ -f "${dir}customer.env" ]; then basename "$dir"; fi
done
return 0
}
# Reads a value from the customer's customer.env. $1 = name, $2 = variable
customer_value() {
sed -n "s/^$2=//p" "$CUSTOMERS_DIR/$1/customer.env" | head -n 1
}
# psql as the PostgreSQL superuser, on the host or in the container
psql_admin() {
if [ "$POSTGRES" = "host" ]; then
# shellcheck disable=SC2086
$PG_ADMIN psql -v ON_ERROR_STOP=1 -q "$@"
else
docker exec -i "$PG_CONTAINER" psql -U postgres -v ON_ERROR_STOP=1 -q "$@"
fi
}
pg_dump_admin() {
if [ "$POSTGRES" = "host" ]; then
# shellcheck disable=SC2086
$PG_ADMIN pg_dump "$@"
else
docker exec "$PG_CONTAINER" pg_dump -U postgres "$@"
fi
}
wait_for_postgres() {
if [ "$POSTGRES" = "host" ]; then
psql_admin -tAc "SELECT 1" >/dev/null 2>&1 \
|| die "Cannot run psql as the PostgreSQL superuser (PG_ADMIN='$PG_ADMIN')"
return 0
fi
for _ in $(seq 1 60); do
# Over TCP: while the image initializes a new cluster, its temporary server listens on the socket only
if docker exec "$PG_CONTAINER" pg_isready -h 127.0.0.1 -U postgres -q 2>/dev/null; then
return 0
fi
sleep 1
done
die "PostgreSQL in container $PG_CONTAINER is not ready"
}
# With PostgreSQL on the host, the containers must be able to reach it over the Docker network
check_db_reachable() {
[ "$POSTGRES" = "host" ] || return 0
docker run --rm --network "$NETWORK" --add-host "host.docker.internal:host-gateway" \
--entrypoint bash "$IMAGE" -c "exec 3<>/dev/tcp/$DB_HOST/$PG_PORT" 2>/dev/null \
|| die "Containers cannot reach PostgreSQL at $DB_HOST:$PG_PORT. Check listen_addresses, pg_hba.conf and the firewall, see README.md."
}
# First free port for a customer container behind nginx
next_port() {
local port="$BASE_PORT" name used
for name in $(all_customers); do
used="$(customer_value "$name" PORT)"
if [ -n "$used" ] && [ "$used" -ge "$port" ]; then
port=$((used + 1))
fi
done
echo "$port"
}
# Writes the nginx server block of a customer to customers/<name>/nginx.conf
write_nginx_conf() {
local name="$1" domain port
domain="$(customer_value "$name" DOMAIN)"
port="$(customer_value "$name" PORT)"
cat > "$CUSTOMERS_DIR/$name/nginx.conf" <<NGINX
# Rezervovator, customer '$name'. Generated by rezervator.sh.
# Install it into nginx and add TLS, e.g.:
# sudo cp $CUSTOMERS_DIR/$name/nginx.conf /etc/nginx/conf.d/$PREFIX-$name.conf
# sudo nginx -t && sudo systemctl reload nginx
# sudo certbot --nginx -d $domain
server {
listen 80;
listen [::]:80;
server_name $domain;
# Banner images are uploaded through the application
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:$port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
NGINX
}
# Starts the container of a customer. $1 = name, $2 = image, $3 = initial admin password (optional)
run_container() {
local name="$1" image="$2" admin_password="${3:-}"
local container router domain port
container="$(container_name "$name")"
router="$PREFIX-$name"
domain="$(customer_value "$name" DOMAIN)"
port="$(customer_value "$name" PORT)"
local args=(
--detach
--name "$container"
--restart unless-stopped
--network "$NETWORK"
--volume "$CUSTOMERS_DIR/$name/config.toml:/app/config/config.toml:ro"
--volume "$(volume_name "$name"):/app/data"
--label "rezervator.stack=$PREFIX"
--label "rezervator.customer=$name"
--label "rezervator.domain=$domain"
)
if [ "$POSTGRES" = "host" ]; then
args+=(--add-host "host.docker.internal:host-gateway")
fi
if [ "$PROXY" = "nginx" ]; then
[ -n "$port" ] || die "Customer '$name' has no PORT in customer.env"
# Only the nginx on this host may reach the container
args+=(--publish "127.0.0.1:$port:3000")
elif [ "$TLS" = "true" ]; then
args+=(
--label "traefik.enable=true"
--label "traefik.http.services.$router.loadbalancer.server.port=3000"
--label "traefik.http.routers.$router.rule=Host(\`$domain\`)"
--label "traefik.http.routers.$router.entrypoints=websecure"
--label "traefik.http.routers.$router.tls.certresolver=le"
--label "traefik.http.routers.$router-http.rule=Host(\`$domain\`)"
--label "traefik.http.routers.$router-http.entrypoints=web"
--label "traefik.http.routers.$router-http.middlewares=$router-https"
--label "traefik.http.middlewares.$router-https.redirectscheme.scheme=https"
)
else
args+=(
--label "traefik.enable=true"
--label "traefik.http.services.$router.loadbalancer.server.port=3000"
--label "traefik.http.routers.$router.rule=Host(\`$domain\`)"
--label "traefik.http.routers.$router.entrypoints=web"
)
fi
if [ -n "$admin_password" ]; then
args+=(--env "REZERVATOR_ADMIN_PASSWORD=$admin_password")
fi
docker run "${args[@]}" "$image" >/dev/null
}
# Waits until the container reports healthy. Returns 1 when it stops or stays unhealthy.
wait_healthy() {
local container="$1" status
for _ in $(seq 1 90); do
status="$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container" 2>/dev/null || echo "missing")"
case "$status" in
"running healthy")
# Traefik picks a new container up a moment after it turns healthy
if [ "$PROXY" = "traefik" ]; then sleep 3; fi
return 0 ;;
# Image without a health check: running for a while is the best we can tell
"running none") sleep 5
[ "$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null)" = "running" ] && return 0
return 1 ;;
"running starting") ;;
"running unhealthy") return 1 ;;
running*) ;;
*) return 1 ;;
esac
sleep 2
done
return 1
}
cmd_init() {
command -v docker >/dev/null || die "docker not found"
command -v openssl >/dev/null || die "openssl not found"
if [ ! -f "$ENV_FILE" ]; then
sed "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(random_string 24)/" "$DEPLOY_DIR/.env.example" > "$ENV_FILE"
chmod 600 "$ENV_FILE"
info "Created $ENV_FILE. Review it (proxy, PostgreSQL, image) and run init again."
exit 0
fi
load_env
if [ "$PROXY" = "traefik" ] && [ "$TLS" = "true" ] && [ -z "${ACME_EMAIL:-}" ]; then
die "REZERVATOR_TLS=true needs ACME_EMAIL in $ENV_FILE"
fi
mkdir -p "$CUSTOMERS_DIR" "$BACKUPS_DIR"
chmod 700 "$CUSTOMERS_DIR" "$BACKUPS_DIR"
compose_up
wait_for_postgres
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
check_db_reachable
else
info "Image $IMAGE is not on this host yet, build or pull it before adding customers"
fi
info "Ready (proxy: $PROXY, PostgreSQL: $POSTGRES)"
}
# Validates the input and creates everything a customer needs except the running container:
# database and role, config, data volume. $1 = name, $2 = domain, $3 = sender address
prepare_customer() {
local name="$1" domain="$2" mail_from="$3"
check_name "$name"
[[ "$domain" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || die "Invalid domain '$domain'"
[[ "$mail_from" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+$ ]] || die "Invalid sender address '$mail_from'"
if customer_exists "$name"; then die "Customer '$name' already exists"; fi
docker image inspect "$IMAGE" >/dev/null 2>&1 || die "Image $IMAGE not found, build or pull it first"
wait_for_postgres
check_db_reachable
local db db_password dir
db="$(db_name "$name")"
db_password="$(random_string 24)"
dir="$CUSTOMERS_DIR/$name"
if [ "$(psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'")" = "1" ]; then
die "Database $db already exists"
fi
if [ "$(psql_admin -tAc "SELECT 1 FROM pg_roles WHERE rolname = '$db'")" = "1" ]; then
die "Database role $db already exists"
fi
info "Creating database $db"
psql_admin <<SQL
CREATE ROLE "$db" LOGIN PASSWORD '$db_password';
CREATE DATABASE "$db" OWNER "$db";
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
SQL
info "Writing config to $dir"
mkdir -p "$dir"
chmod 700 "$dir"
sed -e "s|@SESSION_KEY@|$(random_string 48)|" \
-e "s|@DB_USER@|$db|" \
-e "s|@DB_PASSWORD@|$db_password|" \
-e "s|@DB_NAME@|$db|" \
-e "s|@DB_HOST@|$DB_HOST|" \
-e "s|@DB_PORT@|$PG_PORT|" \
-e "s|@MAX_CONNECTIONS@|$CUSTOMER_MAX_CONNECTIONS|" \
-e "s|@MAIL_FROM@|$mail_from|" \
"$DEPLOY_DIR/config.template.toml" > "$dir/config.toml"
# The directory is closed to other users; the application in the container must be able to read the file
chmod 644 "$dir/config.toml"
if [ "$PROXY" = "nginx" ]; then
# Computed before customer.env exists, so the new customer is not counted
local port
port="$(next_port)"
printf 'DOMAIN=%s\nPORT=%s\n' "$domain" "$port" > "$dir/customer.env"
write_nginx_conf "$name"
else
printf 'DOMAIN=%s\n' "$domain" > "$dir/customer.env"
fi
docker volume create "$(volume_name "$name")" >/dev/null
}
# Starts a prepared customer and waits for it. $1 = name, $2 = initial admin password (optional)
start_new_customer() {
local name="$1" admin_password="${2:-}"
info "Starting container $(container_name "$name")"
run_container "$name" "$IMAGE" "$admin_password"
if ! wait_healthy "$(container_name "$name")"; then
docker logs --tail 20 "$(container_name "$name")" >&2 || true
die "Container $(container_name "$name") did not become healthy. Fix the problem and run 'remove $name --purge' before creating it again."
fi
}
print_address() {
local name="$1" domain
domain="$(customer_value "$name" DOMAIN)"
echo
if [ "$PROXY" = "nginx" ]; then
echo "Customer '$name' is running on 127.0.0.1:$(customer_value "$name" PORT)."
echo "To publish it at $domain, install $CUSTOMERS_DIR/$name/nginx.conf"
echo "into nginx; the commands are at the top of that file."
elif [ "$TLS" = "true" ]; then
echo "Customer '$name' is running at https://$domain"
else
echo "Customer '$name' is running at http://$domain"
fi
}
cmd_add() {
[ $# -ge 2 ] || die "Usage: add <name> <domain> [from]"
local name="$1" domain="$2" mail_from="${3:-noreply@$2}" admin_password
load_env
prepare_customer "$name" "$domain" "$mail_from"
admin_password="$(random_string 9)"
start_new_customer "$name" "$admin_password"
print_address "$name"
cat <<MSG
Admin login: admin
Admin password: $admin_password
The password is shown only now. Hand it over and have it changed after the first login.
MSG
}
cmd_import() {
[ $# -ge 3 ] || die "Usage: import <name> <domain> <database> [data-dir] [from]"
local name="$1" domain="$2" source_db="$3" data_dir="${4:-}" mail_from="${5:-noreply@$2}"
local dir timestamp
load_env
[[ "$source_db" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "Invalid database name '$source_db'"
if [ -n "$data_dir" ] && [ ! -d "$data_dir" ]; then
die "Data directory '$data_dir' not found"
fi
wait_for_postgres
[ "$(psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$source_db'")" = "1" ] \
|| die "Source database '$source_db' not found"
prepare_customer "$name" "$domain" "$mail_from"
# The copy goes through the same files as a backup, so it can be restored again later
dir="$BACKUPS_DIR/$name"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$dir"
chmod 700 "$dir"
info "Copying database $source_db"
pg_dump_admin --no-owner --no-privileges "$source_db" | gzip > "$dir/$timestamp-db.sql.gz"
if [ -n "$data_dir" ]; then
info "Copying files from $data_dir"
# Old installations kept the config file with passwords next to the uploads
tar czf "$dir/$timestamp-data.tar.gz" --exclude='*.toml' -C "$data_dir" .
else
tar czf "$dir/$timestamp-data.tar.gz" --files-from /dev/null
fi
restore_customer "$name" "$timestamp"
# The accounts come with the database, so no initial admin password
start_new_customer "$name"
print_address "$name"
echo
echo "Accounts and passwords were taken over from database $source_db."
if docker logs "$(container_name "$name")" 2>&1 | grep -q "still uses the default password"; then
echo "WARNING: the account 'admin' still has the default password 'admin'. Change it now."
fi
echo "The source database and files were not changed. Stop the old installation before"
echo "switching the domain over, otherwise bookings made there in the meantime are lost."
}
cmd_list() {
load_env
printf '%-20s %-34s %-7s %-18s %s\n' "CUSTOMER" "DOMAIN" "PORT" "STATE" "IMAGE"
local name domain port state image
for name in $(all_customers); do
domain="$(customer_value "$name" DOMAIN)"
port="$(customer_value "$name" PORT)"
state="$(docker inspect --format '{{.State.Status}}{{if .State.Health}}/{{.State.Health.Status}}{{end}}' "$(container_name "$name")" 2>/dev/null || echo "no container")"
image="$(docker inspect --format '{{.Image}}' "$(container_name "$name")" 2>/dev/null || true)"
if [ -n "$image" ]; then
# Tag of the image when it still has one, otherwise the short id
image="$(docker image inspect --format '{{if .RepoTags}}{{index .RepoTags 0}}{{else}}{{slice .Id 7 19}}{{end}}' "$image" 2>/dev/null || echo "${image:7:12}")"
else
image="-"
fi
printf '%-20s %-34s %-7s %-18s %s\n' "$name" "$domain" "${port:--}" "$state" "$image"
done
}
# Dumps the database and the data volume. $1 = name. Prints the timestamp of the backup.
backup_customer() {
local name="$1" db dir timestamp
db="$(db_name "$name")"
dir="$BACKUPS_DIR/$name"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$dir"
chmod 700 "$dir"
pg_dump_admin --no-owner --no-privileges "$db" | gzip > "$dir/$timestamp-db.sql.gz"
# Any image with tar would do; the application image is certainly present
docker run --rm --volume "$(volume_name "$name"):/data:ro" --entrypoint tar "$IMAGE" \
czf - -C /data . > "$dir/$timestamp-data.tar.gz"
# An empty or truncated dump must not pass for a backup
gzip -t "$dir/$timestamp-db.sql.gz" && gzip -t "$dir/$timestamp-data.tar.gz"
[ "$(gzip -dc "$dir/$timestamp-db.sql.gz" | wc -c)" -gt 0 ] || die "Database dump of '$name' is empty"
echo "$timestamp"
}
cmd_backup() {
[ $# -ge 1 ] || die "Usage: backup <name>...|--all"
load_env
wait_for_postgres
local names=("$@") name timestamp
if [ "$1" = "--all" ]; then
# shellcheck disable=SC2207
names=($(all_customers))
fi
[ "${#names[@]}" -gt 0 ] || { info "No customers"; return 0; }
for name in "${names[@]}"; do
require_customer "$name"
timestamp="$(backup_customer "$name")"
info "Backup of '$name': $BACKUPS_DIR/$name/$timestamp-db.sql.gz, $timestamp-data.tar.gz"
done
}
# Replaces the database and the data volume by a backup. The container must be stopped.
restore_customer() {
local name="$1" timestamp="$2" db dir
db="$(db_name "$name")"
dir="$BACKUPS_DIR/$name"
psql_admin <<SQL
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
CREATE DATABASE "$db" OWNER "$db";
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
SQL
# Loaded by the superuser acting as the customer's role, so that role owns everything
{ echo "SET ROLE \"$db\";"; gzip -dc "$dir/$timestamp-db.sql.gz"; } | psql_admin -d "$db" >/dev/null
docker run --rm -i --volume "$(volume_name "$name"):/data" --entrypoint sh "$IMAGE" \
-c 'find /data -mindepth 1 -delete && tar xzf - -C /data' < "$dir/$timestamp-data.tar.gz"
}
cmd_restore() {
[ $# -eq 2 ] || die "Usage: restore <name> <timestamp>"
local name="$1" timestamp="$2" container image
load_env
require_customer "$name"
[ -f "$BACKUPS_DIR/$name/$timestamp-db.sql.gz" ] && [ -f "$BACKUPS_DIR/$name/$timestamp-data.tar.gz" ] \
|| die "Backup $timestamp of '$name' not found in $BACKUPS_DIR/$name"
wait_for_postgres
container="$(container_name "$name")"
# Keep the image the customer runs now; the configured one may be newer than the backup
image="$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null || echo "$IMAGE")"
info "Stopping $container"
docker rm --force "$container" >/dev/null 2>&1 || true
info "Restoring '$name' from backup $timestamp"
restore_customer "$name" "$timestamp"
run_container "$name" "$image"
wait_healthy "$container" || die "Container $container is not healthy after the restore, see 'docker logs $container'"
info "Customer '$name' restored and running"
}
cmd_upgrade() {
[ $# -ge 1 ] || die "Usage: upgrade <name>...|--all"
load_env
docker image inspect "$IMAGE" >/dev/null 2>&1 || die "Image $IMAGE not found, build or pull it first"
wait_for_postgres
local names=("$@") name container old_image new_image timestamp
if [ "$1" = "--all" ]; then
# shellcheck disable=SC2207
names=($(all_customers))
fi
[ "${#names[@]}" -gt 0 ] || { info "No customers"; return 0; }
new_image="$(docker image inspect --format '{{.Id}}' "$IMAGE")"
for name in "${names[@]}"; do
require_customer "$name"
container="$(container_name "$name")"
old_image="$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null || true)"
if [ "$old_image" = "$new_image" ]; then
info "'$name' already runs $IMAGE, skipping"
continue
fi
info "Upgrading '$name'"
timestamp="$(backup_customer "$name")"
docker rm --force "$container" >/dev/null 2>&1 || true
run_container "$name" "$IMAGE"
if wait_healthy "$container"; then
info "'$name' upgraded (backup before the upgrade: $timestamp)"
continue
fi
echo "Upgrade of '$name' failed, last log lines of the new container:" >&2
docker logs --tail 20 "$container" >&2 || true
docker rm --force "$container" >/dev/null 2>&1 || true
if [ -z "$old_image" ]; then
die "'$name' had no container before, nothing to roll back to. Backup: $timestamp"
fi
# Migrations of the new version may have changed the database, so the old version
# gets the database from before the upgrade
info "Rolling '$name' back to the previous image and to backup $timestamp"
restore_customer "$name" "$timestamp"
run_container "$name" "$old_image"
if wait_healthy "$container"; then
die "'$name' was rolled back. Upgrade stopped, the remaining customers were not touched."
fi
die "'$name' is DOWN: the rollback did not become healthy either. Backup: $timestamp"
done
}
cmd_remove() {
[ $# -ge 1 ] || die "Usage: remove <name> [--purge]"
local name="$1" purge="${2:-}" db answer
load_env
require_customer "$name"
info "Removing container $(container_name "$name")"
docker rm --force "$(container_name "$name")" >/dev/null 2>&1 || true
if [ "$purge" != "--purge" ]; then
info "Database, data volume and config of '$name' were kept. Start it again with 'upgrade $name'."
return 0
fi
read -r -p "This deletes the database, uploaded files and config of '$name'. Type the name to confirm: " answer
[ "$answer" = "$name" ] || die "Not confirmed, nothing else was deleted"
wait_for_postgres
db="$(db_name "$name")"
psql_admin <<SQL
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
DROP ROLE IF EXISTS "$db";
SQL
docker volume rm "$(volume_name "$name")" >/dev/null 2>&1 || true
rm -rf "${CUSTOMERS_DIR:?}/$name"
info "Customer '$name' deleted. Backups in $BACKUPS_DIR/$name were kept."
if [ "$PROXY" = "nginx" ]; then
info "Remove its server block from nginx as well."
fi
}
main() {
[ $# -ge 1 ] || { usage; exit 1; }
local command="$1"
shift
case "$command" in
init) cmd_init "$@" ;;
add) cmd_add "$@" ;;
import) cmd_import "$@" ;;
list) cmd_list "$@" ;;
upgrade) cmd_upgrade "$@" ;;
backup) cmd_backup "$@" ;;
restore) cmd_restore "$@" ;;
remove) cmd_remove "$@" ;;
-h|--help|help) usage ;;
*) usage; exit 1 ;;
esac
}
main "$@"
+25 -19
View File
@@ -1,11 +1,14 @@
use crate::locales::init_locales; use crate::locales::init_locales;
use crate::pages::home_page::HomePage; use crate::pages::home_page::HomePage;
use crate::pages::settings::Settings; use crate::pages::settings::Settings;
use leptos::*; use leptos::prelude::*;
use leptos_meta::*; use leptos_meta::*;
use leptos_router::*; use leptos_router::components::{Route, Router, Routes};
use leptos_router::hooks::use_location;
use leptos_router::path;
use crate::components::admin_portal::AdminPortal; use crate::components::admin_portal::AdminPortal;
use crate::components::header::Header; use crate::components::header::Header;
use crate::components::theme::Theme;
use crate::components::user_menu::MenuOpener; use crate::components::user_menu::MenuOpener;
use crate::pages::all_reservations::Bookings; use crate::pages::all_reservations::Bookings;
use crate::pages::appearance_settings::Appearance; use crate::pages::appearance_settings::Appearance;
@@ -14,29 +17,31 @@ use crate::pages::login::Login;
use crate::pages::mail_settings::MailSettings; use crate::pages::mail_settings::MailSettings;
use crate::pages::public::Public; use crate::pages::public::Public;
#[derive(Clone, Copy)] #[derive(Clone, Copy, Debug)]
pub struct MenuHelper { pub struct MenuHelper {
opened: RwSignal<MenuOpener> opened: RwSignal<Option<MenuOpener>>
} }
impl MenuHelper { impl MenuHelper {
pub fn new() -> Self { pub fn new() -> Self {
let opened = create_rw_signal(MenuOpener::new()); let opened = RwSignal::new(None);
Self { Self {
opened opened
} }
} }
pub fn set_opened(&self, opened: MenuOpener) { pub fn set_opened(&self, opened: MenuOpener) {
self.opened.set(opened); self.opened.set(Some(opened));
} }
pub fn close(&self) { pub fn close(&self) {
self.opened.get().close(); if let Some(opened) = self.opened.get_untracked() {
opened.close();
}
} }
pub fn reset(&self) { pub fn reset(&self) {
self.opened.set(MenuOpener::new()); self.opened.set(None);
} }
} }
@@ -47,7 +52,7 @@ pub struct DialogHelper {
impl DialogHelper { impl DialogHelper {
pub fn new() -> Self { pub fn new() -> Self {
let opened = create_rw_signal(false); let opened = RwSignal::new(false);
Self { Self {
opened opened
} }
@@ -68,7 +73,7 @@ fn app_footer() -> impl IntoView {
view! { view! {
<footer class="content-footer footer bg-footer-theme" style={move || if loc.pathname.get().starts_with("/admin") {"display: none;"} else {"display: block;"}}> <footer class="content-footer footer bg-footer-theme" style={move || if loc.pathname.get().starts_with("/admin") {"display: none;"} else {"display: block;"}}>
<div class="mb-2 mb-md-0" align="center"> <div class="mb-2 mb-md-0" style="text-align: center">
<a href="https://rezervovator.cz" target="_blank"><img src="/rezervovator_l.svg" width="110"/></a> {format!(" v {}", env!("CARGO_PKG_VERSION"))} <a href="https://rezervovator.cz" target="_blank"><img src="/rezervovator_l.svg" width="110"/></a> {format!(" v {}", env!("CARGO_PKG_VERSION"))}
</div> </div>
</footer> </footer>
@@ -83,41 +88,42 @@ pub fn App() -> impl IntoView {
provide_context(MenuHelper::new()); // Only one menu can be opened provide_context(MenuHelper::new()); // Only one menu can be opened
provide_context(MenuOpener::new()); // Drawer opener provide_context(MenuOpener::new()); // Drawer opener
provide_context(DialogHelper::new()); // Gray dialog background provide_context(DialogHelper::new()); // Gray dialog background
provide_context(Theme::new()); // Light/dark theme of admin portal
view! { view! {
<Header/> <Header/>
<Router> <Router>
<main> <main>
<Routes> <Routes fallback=|| "Page not found.">
<Route path="" view=|| view! { <Public/> }/> <Route path=path!("") view=|| view! { <Public/> }/>
<Route path="login" view=|| view! { <Login/> }/> <Route path=path!("login") view=|| view! { <Login/> }/>
<Route path="admin" view=|| view! { <Route path=path!("admin") view=|| view! {
<AdminPortal> <AdminPortal>
<HomePage/> <HomePage/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/settings" view=|| view! { <Route path=path!("admin/settings") view=|| view! {
<AdminPortal> <AdminPortal>
<Settings/> <Settings/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/mail_settings" view=|| view! { <Route path=path!("admin/mail_settings") view=|| view! {
<AdminPortal> <AdminPortal>
<MailSettings/> <MailSettings/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/bookings" view=|| view! { <Route path=path!("admin/bookings") view=|| view! {
<AdminPortal> <AdminPortal>
<Bookings/> <Bookings/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/customers" view=|| view! { <Route path=path!("admin/customers") view=|| view! {
<AdminPortal> <AdminPortal>
<Customers/> <Customers/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/appearance" view=|| view! { <Route path=path!("admin/appearance") view=|| view! {
<AdminPortal> <AdminPortal>
<Appearance/> <Appearance/>
</AdminPortal> </AdminPortal>
+170 -26
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, Appearance}; use crate::backend::data::{ApiResponse, Appearance};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
@@ -7,12 +7,13 @@ use crate::components::data_form::ForValidation;
cfg_if! { if #[cfg(feature = "ssr")] { cfg_if! { if #[cfg(feature = "ssr")] {
use std::fs; use std::fs;
use std::path::Path;
use actix_web::{post, Responder}; use actix_web::{post, Responder};
use actix_multipart::Multipart; use actix_multipart::Multipart;
use actix_session::Session; use actix_session::Session;
use actix_web::web::Redirect; use actix_web::web::Redirect;
use std::fs::File; use std::fs::File;
use std::io::{Write, Read}; use std::io::Write;
use futures_util::{StreamExt, TryStreamExt}; use futures_util::{StreamExt, TryStreamExt};
use crate::backend::data::User; use crate::backend::data::User;
use crate::error::AppError; use crate::error::AppError;
@@ -23,8 +24,44 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use regex::Regex; use regex::Regex;
use rand::Rng; use rand::Rng;
use rand::distributions::Alphanumeric; use rand::distributions::Alphanumeric;
use log::{info, warn};
pub async fn check_appearance(pool: &PgPool) -> Result<(), AppError> { /// Style of the public page banner. The template is shipped with the application,
/// the copy in the data directory is the base for generated styles.
const BANNER_CSS: &str = "banner.css";
const BANNER_EXTENSIONS: [&str; 5] = ["jpg", "jpeg", "png", "gif", "webp"];
/// Turns the file name sent by the browser into a safe name inside the data directory.
/// Returns `None` when the file is not an image that can be used as a banner.
pub fn banner_file_name(name: &str) -> Option<String> {
// Only the last path segment, whatever separator the client used
let name = name.rsplit(['/', '\\']).next().unwrap_or_default();
let (stem, extension) = name.rsplit_once('.')?;
if stem.is_empty() || !BANNER_EXTENSIONS.contains(&extension.to_lowercase().as_str()) {
return None
}
let stem = stem.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect::<String>();
Some(format!("{}.{}", stem, extension.to_lowercase()))
}
/// Creates the data directory and puts the banner style template into it, if it is not there yet
pub fn ensure_data_dir(data_dir: &Path, site_root: &Path) -> Result<(), AppError> {
fs::create_dir_all(data_dir)?;
if !data_dir.join(BANNER_CSS).exists() {
info!("Copying {} to data directory {}", BANNER_CSS, data_dir.display());
fs::copy(site_root.join(BANNER_CSS), data_dir.join(BANNER_CSS))?;
}
Ok(())
}
pub async fn check_appearance(pool: &PgPool, data_dir: &Path) -> Result<(), AppError> {
let count: (i64,) = query_as("SELECT COUNT(id) FROM appearance") let count: (i64,) = query_as("SELECT COUNT(id) FROM appearance")
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@@ -37,7 +74,22 @@ cfg_if! { if #[cfg(feature = "ssr")] {
let app = query_as::<_, Appearance>("SELECT * FROM appearance").fetch_one(pool).await?; let app = query_as::<_, Appearance>("SELECT * FROM appearance").fetch_one(pool).await?;
if let None = app.css_name { if let None = app.css_name {
query("UPDATE appearance SET css_name = 'banner.css'").execute(pool).await?; query("UPDATE appearance SET css_name = $1").bind(BANNER_CSS).execute(pool).await?;
}
// The generated style can be missing, e.g. when the data directory was not kept during an upgrade
let css_name = app.css_name.unwrap_or(BANNER_CSS.to_string());
if !data_dir.join(&css_name).exists() {
match app.banner {
Some(banner) if data_dir.join(&banner).exists() => {
warn!("Banner style {} is missing, generating it again", css_name);
modify_style(&banner, pool, data_dir).await?;
}
_ => {
warn!("Banner style {} is missing, using default style", css_name);
query("UPDATE appearance SET css_name = $1").bind(BANNER_CSS).execute(pool).await?;
}
}
} }
Ok(()) Ok(())
@@ -52,10 +104,8 @@ cfg_if! { if #[cfg(feature = "ssr")] {
Ok(()) Ok(())
} }
async fn modify_style(file_name: &str, pool: &PgPool) -> Result<(), AppError> { async fn modify_style(file_name: &str, pool: &PgPool, data_dir: &Path) -> Result<(), AppError> {
let mut css_file = File::open("target/site/data/banner.css")?; let mut css_str = fs::read_to_string(data_dir.join(BANNER_CSS))?;
let mut css_str= String::new();
css_file.read_to_string(&mut css_str)?;
if css_str.contains("/*bg-img*/") { if css_str.contains("/*bg-img*/") {
css_str = css_str.replace("/*bg-img*/", &format!("background-image: url('{}');", file_name)); css_str = css_str.replace("/*bg-img*/", &format!("background-image: url('{}');", file_name));
@@ -64,12 +114,15 @@ cfg_if! { if #[cfg(feature = "ssr")] {
css_str = re.replace(&css_str, &format!("background-image: url('{}')", file_name)).to_string(); css_str = re.replace(&css_str, &format!("background-image: url('{}')", file_name)).to_string();
} }
let old_css: (String,) = query_as("SELECT css_name FROM appearance") let old_css: (Option<String>,) = query_as("SELECT css_name FROM appearance")
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
if old_css.0 != "banner.css" { if let Some(old_css) = old_css.0 {
fs::remove_file(format!("target/site/data/{}", old_css.0))?; if old_css != BANNER_CSS {
// The old style may be already gone
let _ = fs::remove_file(data_dir.join(old_css));
}
} }
let s: String = rand::thread_rng() let s: String = rand::thread_rng()
@@ -78,7 +131,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
.map(char::from) .map(char::from)
.collect(); .collect();
let css_name = format!("banner-{}.css", s); let css_name = format!("banner-{}.css", s);
let mut css_file = File::create(format!("target/site/data/{}", css_name))?; let mut css_file = File::create(data_dir.join(&css_name))?;
css_file.write_all(css_str.as_bytes())?; css_file.write_all(css_str.as_bytes())?;
query("UPDATE appearance SET css_name = $1") query("UPDATE appearance SET css_name = $1")
.bind(css_name) .bind(css_name)
@@ -88,6 +141,30 @@ cfg_if! { if #[cfg(feature = "ssr")] {
Ok(()) Ok(())
} }
async fn save_banner(field: &mut actix_multipart::Field, file_name: &str, app_data: &AppData) -> Result<(), AppError> {
let data_dir = app_data.data_dir();
let old_banner: (Option<String>,) = query_as("SELECT banner FROM appearance")
.fetch_one(app_data.db_pool())
.await?;
let mut file = File::create(data_dir.join(file_name))?;
while let Some(chunk) = field.next().await {
let chunk = chunk.map_err(|e| AppError::FileIOError(e.to_string()))?;
file.write_all(&chunk)?;
}
set_banner_name(file_name, app_data.db_pool()).await?;
modify_style(file_name, app_data.db_pool(), data_dir).await?;
if let Some(old_banner) = old_banner.0 {
if old_banner != file_name {
let _ = fs::remove_file(data_dir.join(old_banner));
}
}
Ok(())
}
#[post("/upload")] #[post("/upload")]
pub async fn upload(mut data: Multipart, session: Session, app_data: Data<AppData>) -> impl Responder { pub async fn upload(mut data: Multipart, session: Session, app_data: Data<AppData>) -> impl Responder {
let user = session.get::<User>("user").unwrap_or(None); let user = session.get::<User>("user").unwrap_or(None);
@@ -103,21 +180,23 @@ cfg_if! { if #[cfg(feature = "ssr")] {
} }
if let Ok(Some(mut field)) = data.try_next().await { if let Ok(Some(mut field)) = data.try_next().await {
let content_disp = field.content_disposition(); let sent_name = field.content_disposition()
let file_name = content_disp.unwrap().get_filename().unwrap().to_string(); .and_then(|cd| cd.get_filename())
.unwrap_or_default()
.to_string();
if file_name.is_empty() { match banner_file_name(&sent_name) {
return Redirect::to("/admin/appearance").see_other(); Some(file_name) => {
if let Err(e) = save_banner(&mut field, &file_name, &app_data).await {
warn!("Banner {} not saved: {}", file_name, e);
}
}
None => {
if !sent_name.is_empty() {
warn!("Uploaded file {:?} is not a supported image", sent_name);
}
}
} }
let mut file = File::create(format!("target/site/data/{}", file_name)).unwrap();
let _name = field.name();
while let Some(chunk) = field.next().await {
let c = chunk.unwrap();
let _ = file.write_all(&c);
}
let _ = set_banner_name(&file_name, &app_data.db_pool).await;
let _ = modify_style(&file_name, &app_data.db_pool).await;
} }
Redirect::to("/admin/appearance").see_other() Redirect::to("/admin/appearance").see_other()
@@ -163,11 +242,13 @@ impl ForValidation for UpdateAppearance {
pub async fn delete_banner() -> Result<ApiResponse<()>, ServerFnError> { pub async fn delete_banner() -> Result<ApiResponse<()>, ServerFnError> {
use std::fs; use std::fs;
use crate::perm_check; use crate::perm_check;
use crate::backend::get_data_dir;
perm_check!(is_admin); perm_check!(is_admin);
let appearance = get_appearance().await?; let appearance = get_appearance().await?;
let pool = get_pool().await?; let pool = get_pool().await?;
let data_dir = get_data_dir().await?;
query("UPDATE appearance SET banner = $1 WHERE id = $2") query("UPDATE appearance SET banner = $1 WHERE id = $2")
.bind(None::<String>) .bind(None::<String>)
.bind(appearance.id()) .bind(appearance.id())
@@ -175,8 +256,71 @@ pub async fn delete_banner() -> Result<ApiResponse<()>, ServerFnError> {
.await?; .await?;
if let Some(f) = appearance.banner { if let Some(f) = appearance.banner {
fs::remove_file(format!("target/site/data/{}", f))?; // The file may be already gone
let _ = fs::remove_file(data_dir.join(f));
} }
Ok(ApiResponse::Data(())) Ok(ApiResponse::Data(()))
} }
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
#[test]
fn banner_file_name_keeps_simple_names() {
assert_eq!(banner_file_name("banner1.jpg"), Some("banner1.jpg".to_string()));
assert_eq!(banner_file_name("my-banner_2.PNG"), Some("my-banner_2.png".to_string()));
}
#[test]
fn banner_file_name_strips_directories() {
assert_eq!(banner_file_name("../../etc/banner.jpg"), Some("banner.jpg".to_string()));
assert_eq!(banner_file_name("/etc/banner.jpg"), Some("banner.jpg".to_string()));
assert_eq!(banner_file_name("C:\\Users\\joe\\banner.jpg"), Some("banner.jpg".to_string()));
assert_eq!(banner_file_name("..\\..\\banner.jpg"), Some("banner.jpg".to_string()));
}
#[test]
fn banner_file_name_replaces_unsafe_characters() {
assert_eq!(banner_file_name("můj banner (1).jpg"), Some("m_j_banner__1_.jpg".to_string()));
assert_eq!(banner_file_name("a'b\"c.gif"), Some("a_b_c.gif".to_string()));
assert_eq!(banner_file_name("archive.tar.webp"), Some("archive_tar.webp".to_string()));
}
#[test]
fn banner_file_name_rejects_other_files() {
for name in ["", "banner", "banner.css", "config.toml", "index.html", "script.js", "image.svg", ".jpg", "..", "dir/"] {
assert_eq!(banner_file_name(name), None, "name: {:?}", name);
}
}
#[test]
fn ensure_data_dir_copies_template_once() {
let base = std::env::temp_dir().join(format!("rezervator-test-data-{}", std::process::id()));
let site_root = base.join("site");
let data_dir = base.join("data/nested");
fs::create_dir_all(&site_root).unwrap();
fs::write(site_root.join(BANNER_CSS), "template").unwrap();
ensure_data_dir(&data_dir, &site_root).unwrap();
assert_eq!(fs::read_to_string(data_dir.join(BANNER_CSS)).unwrap(), "template");
// An existing file is not overwritten
fs::write(data_dir.join(BANNER_CSS), "customized").unwrap();
ensure_data_dir(&data_dir, &site_root).unwrap();
assert_eq!(fs::read_to_string(data_dir.join(BANNER_CSS)).unwrap(), "customized");
fs::remove_dir_all(base).unwrap();
}
#[test]
fn ensure_data_dir_fails_without_template() {
let base = std::env::temp_dir().join(format!("rezervator-test-notemplate-{}", std::process::id()));
fs::create_dir_all(base.join("site")).unwrap();
assert!(ensure_data_dir(&base.join("data"), &base.join("site")).is_err());
fs::remove_dir_all(base).unwrap();
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, Company}; use crate::backend::data::{ApiResponse, Company};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
@@ -25,7 +25,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
} }
}} }}
#[server(GetCompany, "/api", "Url", "get_company")] #[server(name = GetCompany, prefix = "/api", endpoint = "get_company")]
pub async fn get_company() -> Result<ApiResponse<Company>, ServerFnError> { pub async fn get_company() -> Result<ApiResponse<Company>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
@@ -40,7 +40,7 @@ pub async fn get_company() -> Result<ApiResponse<Company>, ServerFnError> {
Ok(ApiResponse::Data(cmp)) Ok(ApiResponse::Data(cmp))
} }
#[server(UpdateCompany, "/api", "Url", "update_company")] #[server(name = UpdateCompany, prefix = "/api", endpoint = "update_company")]
pub async fn update_company(company: Company) -> Result<ApiResponse<()>, ServerFnError> { pub async fn update_company(company: Company) -> Result<ApiResponse<()>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
+1 -2
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::{server, ServerFnError}; use leptos::prelude::*;
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::backend::data::Customer; use crate::backend::data::Customer;
@@ -8,7 +8,6 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use sqlx::{query_as, query}; use sqlx::{query_as, query};
use sqlx::Error; use sqlx::Error;
use std::ops::DerefMut; use std::ops::DerefMut;
use leptos::expect_context;
use leptos_actix::extract; use leptos_actix::extract;
use actix_web::cookie::Cookie; use actix_web::cookie::Cookie;
use actix_web::cookie::SameSite; use actix_web::cookie::SameSite;
+91 -11
View File
@@ -309,7 +309,19 @@ impl TryFrom<&str> for DayHour {
return Err(AppError::HourParseError) return Err(AppError::HourParseError)
} }
let times = value.split("-") // Optional discount in parentheses after the interval: "08:00 - 12:00 (10)"
let (interval, discount) = match value.trim().strip_suffix(")") {
Some(rest) => {
let (interval, discount) = rest.rsplit_once("(").ok_or(AppError::HourParseError)?;
if discount.is_empty() || !discount.chars().all(|c| c.is_ascii_digit()) {
return Err(AppError::HourParseError)
}
(interval, Some(discount.parse::<i32>().map_err(|_| AppError::HourParseError)?))
}
None => (value, None)
};
let times = interval.split("-")
.map(|t| t.trim()).collect::<Vec<_>>(); .map(|t| t.trim()).collect::<Vec<_>>();
if times.len() != 2 { if times.len() != 2 {
@@ -326,31 +338,41 @@ impl TryFrom<&str> for DayHour {
Ok(DayHour { Ok(DayHour {
from: from.unwrap(), from: from.unwrap(),
to: to.unwrap(), to: to.unwrap(),
discount: None discount
}) })
} }
} }
fn hours_error(code: &'static str, message: &'static str) -> ValidationError {
let mut err = ValidationError::new(code);
err.message = Some(message.into());
err
}
fn validate_hours(value: &WeekHours) -> Result<(), ValidationError> { fn validate_hours(value: &WeekHours) -> Result<(), ValidationError> {
if value.hours().is_empty() { // Wrong format is already reported by the regex on the field
if value.hours().is_empty() || !RE_HOURS.is_match(value.hours()) {
return Ok(()) return Ok(())
} }
if let Ok(h) = DayHours::try_new(value.hours()) { if let Ok(h) = DayHours::try_new(value.hours()) {
for hr in h.hours() { for hr in h.hours() {
if hr.from() >= hr.to() { return Err(ValidationError::new("TO_BEFORE_FROM")) } if hr.from() >= hr.to() {
return Err(hours_error("TO_BEFORE_FROM", "Time 'to' must be after time 'from'"))
}
} }
Ok(()) Ok(())
} else { } else {
Ok(()) Err(hours_error("HOURS_FORMAT", "Hours must be in HH:MM - HH:MM format"))
} }
} }
lazy_static! { lazy_static! {
static ref RE_HOURS: Regex = Regex::new(r"^$|(^\d{2}:\d{2} ?- ?\d{2}:\d{2} ?(\(\d+\))?,? ?)+").unwrap(); static ref RE_HOURS: Regex = Regex::new(
r"^$|^\d{2}:\d{2} ?- ?\d{2}:\d{2}( ?\(\d+\))?( ?, ?\d{2}:\d{2} ?- ?\d{2}:\d{2}( ?\(\d+\))?)*$").unwrap();
} }
#[derive(Clone, Serialize, Deserialize, Debug, Validate)] #[derive(Clone, Serialize, Deserialize, Debug, Validate)]
#[validate(schema(function = "validate_hours", message = "Time 'to' must be after time 'from'"))] #[validate(schema(function = "validate_hours"))]
pub struct WeekHours { pub struct WeekHours {
day: Weekday, day: Weekday,
#[validate(regex(path = "RE_HOURS", message = "Hours must be in HH:MM - HH:MM format"))] #[validate(regex(path = "RE_HOURS", message = "Hours must be in HH:MM - HH:MM format"))]
@@ -882,6 +904,24 @@ mod tests {
} }
} }
#[test]
fn day_hour_parses_discount() {
let expected = DayHour::new(time(8, 0), time(12, 30), Some(10));
assert_eq!(DayHour::try_from("08:00 - 12:30 (10)"), Ok(expected.clone()));
assert_eq!(DayHour::try_from("08:00-12:30(10)"), Ok(expected));
assert_eq!(DayHour::try_from("08:00 - 12:30 (0)"), Ok(DayHour::new(time(8, 0), time(12, 30), Some(0))));
}
#[test]
fn day_hour_rejects_malformed_discount() {
for input in ["08:00 - 12:00 ()", "08:00 - 12:00 (x)", "08:00 - 12:00 (-5)", "08:00 - 12:00 (1.5)", "08:00 - 12:00 (10",
"08:00 - 12:00 10)", "(10) 08:00 - 12:00", "08:00 (10) - 12:00", "08:00 - 12:00 (10) (20)", "(10)",
"08:00 - 12:00 (99999999999)"] {
assert_eq!(DayHour::try_from(input), Err(AppError::HourParseError), "input: {:?}", input);
}
}
#[test] #[test]
fn day_hours_empty_string_is_closed_day() { fn day_hours_empty_string_is_closed_day() {
let hours = DayHours::try_new("").unwrap(); let hours = DayHours::try_new("").unwrap();
@@ -920,7 +960,8 @@ mod tests {
fn day_hours_display_round_trip() { fn day_hours_display_round_trip() {
let hours = DayHours(vec![ let hours = DayHours(vec![
DayHour::new(time(8, 0), time(12, 0), None), DayHour::new(time(8, 0), time(12, 0), None),
DayHour::new(time(13, 0), time(17, 0), None) DayHour::new(time(13, 0), time(17, 0), Some(10)),
DayHour::new(time(18, 0), time(20, 0), Some(0))
]); ]);
let parsed = DayHours::try_new(&hours.to_string()).unwrap(); let parsed = DayHours::try_new(&hours.to_string()).unwrap();
@@ -940,23 +981,62 @@ mod tests {
#[test] #[test]
fn week_hours_accepts_valid_hours() { fn week_hours_accepts_valid_hours() {
for input in ["", "08:00 - 12:00", "08:00-12:00", "08:00 - 12:00, 13:00 - 17:00"] { for input in ["", "08:00 - 12:00", "08:00-12:00", "08:00 - 12:00, 13:00 - 17:00",
"08:00 - 12:00 (10)", "08:00 - 12:00 (10), 13:00 - 17:00", "08:00 - 12:00, 13:00 - 17:00(20)"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() }; let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
assert!(hours.validate().is_ok(), "input: {:?}", input); assert!(hours.validate().is_ok(), "input: {:?}", input);
assert!(DayHours::try_new(input).is_ok(), "input: {:?}", input);
} }
} }
#[test] #[test]
fn week_hours_rejects_wrong_format() { fn week_hours_rejects_wrong_format() {
for input in ["8-12", "8:00 - 12:00", "morning", "08.00 - 12.00"] { for input in ["8-12", "8:00 - 12:00", "morning", "08.00 - 12.00",
"08:00 - 12:00, nonsense", "08:00 - 12:00 garbage", "08:00 - 12:00,", ", 08:00 - 12:00"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() }; let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
assert!(has_field_error(&hours, "hours"), "input: {:?}", input); assert!(has_field_error(&hours, "hours"), "input: {:?}", input);
assert!(!has_field_error(&hours, "__all__"), "input: {:?}", input);
} }
} }
#[test]
fn week_hours_rejects_hours_that_cannot_be_parsed() {
// Matches the format, but is not a valid time
for input in ["25:00 - 26:00", "08:00 - 12:60", "08:00 - 12:00, 13:00 - 24:00"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
assert!(has_field_error(&hours, "__all__"), "input: {:?}", input);
}
}
#[test]
fn week_hours_validation_agrees_with_parser() {
for input in ["", "08:00 - 12:00", "08:00-12:00", "08:00 - 12:00,13:00 - 17:00", "08:00 - 12:00 , 13:00 - 17:00",
"08:00 - 12:00 (10)", "08:00 - 12:00 (10) , 13:00 - 17:00 (5)", "08:00 - 12:00 (99999999999)", "08:00 - 12:00, nonsense", "08:00 - 12:00,", "25:00 - 26:00", "08:00 - 12:00 garbage"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
if hours.validate().is_ok() {
assert!(DayHours::try_new(input).is_ok(), "input: {:?}", input);
}
}
}
#[test]
fn week_hours_error_messages() {
let message = |input: &str| {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
let errors = hours.validate().unwrap_err();
let all = errors.field_errors().values().flat_map(|e| e.iter()).map(|e| e.message.clone().unwrap().to_string()).collect::<Vec<_>>();
assert_eq!(all.len(), 1, "input: {:?}", input);
all[0].clone()
};
assert_eq!(message("nonsense"), "Hours must be in HH:MM - HH:MM format");
assert_eq!(message("25:00 - 26:00"), "Hours must be in HH:MM - HH:MM format");
assert_eq!(message("12:00 - 08:00"), "Time 'to' must be after time 'from'");
}
#[test] #[test]
fn week_hours_rejects_to_before_from() { fn week_hours_rejects_to_before_from() {
for input in ["12:00 - 08:00", "08:00 - 08:00", "08:00 - 12:00, 17:00 - 13:00"] { for input in ["12:00 - 08:00", "08:00 - 08:00", "08:00 - 12:00, 17:00 - 13:00", "12:00 - 08:00 (10)"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() }; let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
assert!(has_field_error(&hours, "__all__"), "input: {:?}", input); assert!(has_field_error(&hours, "__all__"), "input: {:?}", input);
} }
+94
View File
@@ -0,0 +1,94 @@
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;
use actix_web::{get, HttpResponse, Responder};
use actix_web::web::Data;
use log::error;
use crate::backend::AppData;
/// Health of the instance for the reverse proxy and for monitoring. The application is
/// healthy when it can talk to its database.
#[get("/health")]
pub async fn health(app_data: Data<AppData>) -> impl Responder {
match sqlx::query("SELECT 1").execute(app_data.db_pool()).await {
Ok(_) => HttpResponse::Ok().body("ok"),
Err(e) => {
error!("Health check failed: {}", e);
HttpResponse::ServiceUnavailable().body("database unavailable")
}
}
}
/// Asks a running instance on this machine for its health. Used by the container health
/// check (`rezervator --healthcheck`), so the image doesn't need curl.
pub fn is_healthy(port: u16) -> bool {
let timeout = Duration::from_secs(3);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let Ok(mut stream) = TcpStream::connect_timeout(&addr, timeout) else {
return false
};
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
if stream.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").is_err() {
return false
}
let mut response = [0u8; 64];
let read = stream.read(&mut response).unwrap_or(0);
String::from_utf8_lossy(&response[..read]).lines().next()
.map(|status| {
let mut parts = status.split_whitespace();
parts.next().is_some_and(|version| version.starts_with("HTTP/")) && parts.next() == Some("200")
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
/// Starts a server that answers one request with the given response, returns its port
fn serve_once(response: &'static str) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut request = [0u8; 512];
let _ = stream.read(&mut request);
let _ = stream.write_all(response.as_bytes());
}
});
port
}
#[test]
fn healthy_when_server_answers_200() {
assert!(is_healthy(serve_once("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")));
}
#[test]
fn unhealthy_when_database_is_down() {
assert!(!is_healthy(serve_once("HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\n\r\n")));
}
#[test]
fn unhealthy_on_other_answers() {
assert!(!is_healthy(serve_once("HTTP/1.1 302 Found\r\nlocation: /login\r\n\r\n")));
assert!(!is_healthy(serve_once("")));
assert!(!is_healthy(serve_once("garbage 200")));
}
#[test]
fn unhealthy_when_nothing_listens() {
// Take a free port and release it again
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
assert!(!is_healthy(port));
}
}
+45 -19
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, Message, MessageType}; use crate::backend::data::{ApiResponse, Message, MessageType};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
@@ -19,7 +19,6 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use lettre::transport::smtp::client::{Tls, TlsParameters}; use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::transport::smtp::authentication::Credentials; use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::extension::ClientId; use lettre::transport::smtp::extension::ClientId;
use std::ops::Add;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::sync::mpsc; use std::sync::mpsc;
@@ -81,15 +80,12 @@ cfg_if! { if #[cfg(feature = "ssr")] {
fn replace_body_vars(text: String, reservation: &ResSumWithItems) -> String { fn replace_body_vars(text: String, reservation: &ResSumWithItems) -> String {
text text
.replace("#date#", &reservation.summary.date.format("%d. %m. %Y").to_string()) .replace("#date#", &reservation.summary.date.format("%d. %m. %Y").to_string())
.replace("#summary#", &{ .replace("#summary#", &reservation.reservations.iter()
let mut sum = "".to_string(); .map(|p| format!("{}: {} - {}",
for p in &reservation.reservations { p.property.name,
sum = sum.add(&format!("{}: {} - {}", p.reservation.from.format("%H:%M"), p.reservation.to.format("%H:%M")))
p.property.name, .collect::<Vec<_>>()
p.reservation.from.format("%H:%M").to_string(), p.reservation.to.format("%H:%M").to_string())); .join("\n")
}
sum
}
) )
} }
@@ -267,19 +263,23 @@ mod tests {
use chrono::{NaiveDate, NaiveTime}; use chrono::{NaiveDate, NaiveTime};
use crate::backend::data::{Reservation, ResProperty, ResWithProperty}; use crate::backend::data::{Reservation, ResProperty, ResWithProperty};
fn reservation() -> ResSumWithItems { fn item(name: &str, from: (u32, u32), to: (u32, u32)) -> ResWithProperty {
let mut property = ResProperty::default(); let mut property = ResProperty::default();
property.name = "Court 1".to_string(); property.name = name.to_string();
let mut reservation = ResSumWithItems::default(); ResWithProperty {
reservation.summary.date = NaiveDate::from_ymd_opt(2030, 3, 5).unwrap();
reservation.reservations.push(ResWithProperty {
reservation: Reservation::new( reservation: Reservation::new(
NaiveTime::from_hms_opt(8, 0, 0).unwrap(), NaiveTime::from_hms_opt(from.0, from.1, 0).unwrap(),
NaiveTime::from_hms_opt(9, 30, 0).unwrap(), NaiveTime::from_hms_opt(to.0, to.1, 0).unwrap(),
1), 1),
property property
}); }
}
fn reservation() -> ResSumWithItems {
let mut reservation = ResSumWithItems::default();
reservation.summary.date = NaiveDate::from_ymd_opt(2030, 3, 5).unwrap();
reservation.reservations.push(item("Court 1", (8, 0), (9, 30)));
reservation reservation
} }
@@ -304,6 +304,32 @@ mod tests {
assert_eq!(mail.text, "Date: 05. 03. 2030, items: Court 1: 08:00 - 09:30."); assert_eq!(mail.text, "Date: 05. 03. 2030, items: Court 1: 08:00 - 09:30.");
} }
#[test]
fn mail_message_summary_has_one_item_per_line() {
let mut reservation = reservation();
reservation.reservations.push(item("Court 2", (10, 0), (11, 0)));
reservation.reservations.push(item("Sauna", (18, 0), (20, 0)));
let mail = MailMessage::new(
"[email protected]".to_string(),
"[email protected]".to_string(),
message("Booking", "Items:\n#summary#\nBye"),
&reservation);
assert_eq!(mail.text, "Items:\nCourt 1: 08:00 - 09:30\nCourt 2: 10:00 - 11:00\nSauna: 18:00 - 20:00\nBye");
}
#[test]
fn mail_message_summary_is_empty_without_items() {
let mail = MailMessage::new(
"[email protected]".to_string(),
"[email protected]".to_string(),
message("Booking", "Items: #summary#."),
&ResSumWithItems::default());
assert_eq!(mail.text, "Items: .");
}
#[test] #[test]
fn mail_message_without_variables_is_unchanged() { fn mail_message_without_variables_is_unchanged() {
let mail = MailMessage::new( let mail = MailMessage::new(
+19 -4
View File
@@ -9,6 +9,8 @@ pub mod reservation;
pub mod customer; pub mod customer;
pub mod mail; pub mod mail;
pub mod appearance; pub mod appearance;
#[cfg(feature = "ssr")]
pub mod health;
#[macro_export] #[macro_export]
macro_rules! perm_check { macro_rules! perm_check {
@@ -54,23 +56,31 @@ cfg_if!{
use sqlx::PgPool; use sqlx::PgPool;
use actix_web::web::Data; use actix_web::web::Data;
use leptos_actix::extract; use leptos_actix::extract;
use leptos::ServerFnError; use leptos::prelude::ServerFnError;
use crate::backend::mail::Mailer; use crate::backend::mail::Mailer;
use std::path::PathBuf;
#[derive(Clone)] #[derive(Clone)]
pub struct AppData { pub struct AppData {
db_pool: PgPool, db_pool: PgPool,
mailer: Mailer mailer: Mailer,
data_dir: PathBuf
} }
impl AppData { impl AppData {
pub fn new(db_pool: PgPool, mailer: Mailer) -> Self { pub fn new(db_pool: PgPool, mailer: Mailer, data_dir: PathBuf) -> Self {
Self { Self {
db_pool, db_pool,
mailer mailer,
data_dir
} }
} }
/// Directory for files created at runtime, served under `/data`
pub fn data_dir(&self) -> &PathBuf {
&self.data_dir
}
pub fn db_pool(&self) -> &PgPool { pub fn db_pool(&self) -> &PgPool {
&self.db_pool &self.db_pool
} }
@@ -85,6 +95,11 @@ cfg_if!{
Ok(data.db_pool().clone()) Ok(data.db_pool().clone())
} }
pub async fn get_data_dir() -> Result<PathBuf, ServerFnError> {
let data = extract::<Data<AppData>>().await?;
Ok(data.data_dir().clone())
}
pub async fn get_mailing() -> Result<Mailer, ServerFnError> { pub async fn get_mailing() -> Result<Mailer, ServerFnError> {
let data = extract::<Data<AppData>>().await?; let data = extract::<Data<AppData>>().await?;
Ok(data.mailer().clone()) Ok(data.mailer().clone())
+1 -1
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use cfg_if::cfg_if; use cfg_if::cfg_if;
use chrono::Weekday; use chrono::Weekday;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, ClosingTime, DayHour, WeekHours}; use crate::backend::data::{ApiResponse, ClosingTime, DayHour, WeekHours};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
+1 -1
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, ResProperty}; use crate::backend::data::{ApiResponse, ResProperty};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
+12 -8
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, CrReservation, Reservation, PublicFormData, ResSumWithItems, Page, PagedResponse}; use crate::backend::data::{ApiResponse, CrReservation, Reservation, PublicFormData, ResSumWithItems, Page, PagedResponse};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
@@ -449,8 +449,8 @@ pub async fn cancel(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String { fn chart(data: &Vec<ChartData>, title: &str, month: bool, dark: bool) -> String {
use charts_rs::{BarChart, THEME_ANT}; use charts_rs::{BarChart, THEME_ANT, THEME_DARK};
use base64::Engine; use base64::Engine;
use base64::prelude::BASE64_STANDARD; use base64::prelude::BASE64_STANDARD;
use std::ops::Add; use std::ops::Add;
@@ -463,7 +463,11 @@ fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String {
} else { } else {
data.iter().map(|d| d.period.to_string()).collect() data.iter().map(|d| d.period.to_string()).collect()
}, },
THEME_ANT); if dark { THEME_DARK } else { THEME_ANT });
if dark {
// Card background of the dark theme, see assets/css/dark.css
chart.background_color = (43, 44, 64).into();
}
chart.title_text = title.to_string(); chart.title_text = title.to_string();
chart.legend_show = Some(false); chart.legend_show = Some(false);
chart.series_label_font_size = 8.0; chart.series_label_font_size = 8.0;
@@ -480,21 +484,21 @@ fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String {
} }
#[server] #[server]
pub async fn month_chart(year: i32) -> Result<ApiResponse<String>, ServerFnError> { pub async fn month_chart(year: i32, dark: bool) -> Result<ApiResponse<String>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
perm_check!(is_logged_in); perm_check!(is_logged_in);
let data = month_chart_data(year).await?; let data = month_chart_data(year).await?;
Ok(ApiResponse::Data(chart(&data, &trl("Month bookings")(), true))) Ok(ApiResponse::Data(chart(&data, &trl("Month bookings")(), true, dark)))
} }
#[server] #[server]
pub async fn year_chart() -> Result<ApiResponse<String>, ServerFnError> { pub async fn year_chart(dark: bool) -> Result<ApiResponse<String>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
perm_check!(is_logged_in); perm_check!(is_logged_in);
let data = year_chart_data().await?; let data = year_chart_data().await?;
Ok(ApiResponse::Data(chart(&data, &trl("Year bookings")(), false))) Ok(ApiResponse::Data(chart(&data, &trl("Year bookings")(), false, dark)))
} }
#[server] #[server]
+15 -5
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, PwdChange, User, UserProfile}; use crate::backend::data::{ApiResponse, PwdChange, User, UserProfile};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
@@ -11,6 +11,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use log::{info, warn}; use log::{info, warn};
use crate::error::AppError; use crate::error::AppError;
use crate::backend::get_pool; use crate::backend::get_pool;
use crate::config::Admin;
pub async fn has_admin_user(pool: &PgPool) -> Result<bool, Error> { pub async fn has_admin_user(pool: &PgPool) -> Result<bool, Error> {
let count: (i64,) = query_as(r#"SELECT COUNT(id) FROM "user" WHERE admin = $1"#) let count: (i64,) = query_as(r#"SELECT COUNT(id) FROM "user" WHERE admin = $1"#)
@@ -21,15 +22,24 @@ cfg_if! { if #[cfg(feature = "ssr")] {
Ok(count.0 > 0) Ok(count.0 > 0)
} }
pub async fn create_admin(pool: &PgPool) -> Result<(), AppError> { /// Creates the first admin account when there is none. The password comes from the
/// configuration, there is no default one.
pub async fn create_admin(pool: &PgPool, admin_conf: &Admin) -> Result<(), AppError> {
if !has_admin_user(pool).await? { if !has_admin_user(pool).await? {
let pwd = pwhash::bcrypt::hash("admin"); let password = admin_conf.initial_password().map_err(AppError::FatalError)?;
let pwd = pwhash::bcrypt::hash(password).map_err(|e| AppError::FatalError(e.to_string()))?;
query(r#"INSERT INTO "user"(login, password, full_name, admin) VALUES($1, $2, $3, $4)"#) query(r#"INSERT INTO "user"(login, password, full_name, admin) VALUES($1, $2, $3, $4)"#)
.bind("admin") .bind("admin")
.bind(pwd.unwrap()) .bind(pwd)
.bind("Admin User") .bind("Admin User")
.bind(true) .bind(true)
.execute(pool).await?; .execute(pool).await?;
info!("Created admin account with the configured initial password");
} else if let Ok(admin) = user_from_login(pool, "admin").await {
// Installations created by older versions started with a known password
if pwhash::bcrypt::verify("admin", &admin.password) {
warn!("Account admin still uses the default password, change it!");
}
} }
Ok(()) Ok(())
@@ -157,7 +167,7 @@ pub async fn get_user() -> Result<Option<User>, ServerFnError> {
Ok(logged_in_user().await) Ok(logged_in_user().await)
} }
#[server(GetUsers, "/api", "Url", "get_users")] #[server(name = GetUsers, prefix = "/api", endpoint = "get_users")]
pub async fn get_users() -> Result<ApiResponse<Vec<User>>, ServerFnError> { pub async fn get_users() -> Result<ApiResponse<Vec<User>>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
use crate::backend::get_pool; use crate::backend::get_pool;
+17 -4
View File
@@ -1,6 +1,7 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::User; use crate::backend::data::User;
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog}; use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
use crate::components::theme::Theme;
use crate::components::user_menu::{MenuOpener, UserMenu}; use crate::components::user_menu::{MenuOpener, UserMenu};
use crate::locales::trl; use crate::locales::trl;
use crate::pages::change_pwd::ChangePassword; use crate::pages::change_pwd::ChangePassword;
@@ -12,7 +13,7 @@ fn about(opener: DialogOpener) -> impl IntoView {
<ModalDialog opener=opener title=""> <ModalDialog opener=opener title="">
<ModalBody> <ModalBody>
<img src="/rezervovator_l.svg" width="180"/> <br /><br/> <img src="/rezervovator_l.svg" width="180"/> <br /><br/>
<p> <div>
{trl("Online booking application for sports facilities and service providers.")}<br/> {trl("Online booking application for sports facilities and service providers.")}<br/>
{format!(" v {}", env!("CARGO_PKG_VERSION"))}<br/><br/> {format!(" v {}", env!("CARGO_PKG_VERSION"))}<br/><br/>
<div align="center"> <div align="center">
@@ -20,7 +21,7 @@ fn about(opener: DialogOpener) -> impl IntoView {
<a href="https://leptos.dev" target="_blank"><img src="/Leptos_logo.png" height="40"/></a> <br/><br/> <a href="https://leptos.dev" target="_blank"><img src="/Leptos_logo.png" height="40"/></a> <br/><br/>
"(c) 2023 - 2025" "(c) 2023 - 2025"
</div> </div>
</p> </div>
</ModalBody> </ModalBody>
</ModalDialog> </ModalDialog>
} }
@@ -66,13 +67,16 @@ fn settings_menu(opener: MenuOpener) -> impl IntoView {
pub fn AdminPortal(children: Children) -> impl IntoView { pub fn AdminPortal(children: Children) -> impl IntoView {
let user_menu = MenuOpener::new(); let user_menu = MenuOpener::new();
let settings_menu = MenuOpener::new(); let settings_menu = MenuOpener::new();
let (user, set_user) = create_signal(User::default()); let (user, set_user) = signal(User::default());
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let pw_changer = DialogOpener::new(); let pw_changer = DialogOpener::new();
let drawer = use_context::<MenuOpener>().expect("No drawer opener"); let drawer = use_context::<MenuOpener>().expect("No drawer opener");
let about_dlg = DialogOpener::new(); let about_dlg = DialogOpener::new();
let theme = use_context::<Theme>().expect("No theme");
view! { view! {
// Dark theme is limited to the admin portal: the stylesheet is loaded only here
<link rel="stylesheet" href="/css/dark.css" media=move || theme.mode().media()/>
<div class="layout-wrapper layout-content-navbar"> <div class="layout-wrapper layout-content-navbar">
<div class="layout-container"> <div class="layout-container">
//<!-- Menu --> //<!-- Menu -->
@@ -141,6 +145,15 @@ pub fn AdminPortal(children: Children) -> impl IntoView {
<ul class="navbar-nav flex-row align-items-center ms-auto"> <ul class="navbar-nav flex-row align-items-center ms-auto">
//<!-- Theme -->
<li class="nav-item">
<a class="nav-link" href="javascript:void(0)"
title=move || trl(theme.mode().label())()
on:click=move |_| theme.cycle()>
<i class=move || theme.mode().icon()></i>
</a>
</li>
<li class="nav-item navbar-dropdown dropdown-user dropdown"> <li class="nav-item navbar-dropdown dropdown-user dropdown">
<a class="nav-link dropdown-toggle hide-arrow" href="#" on:click=move |_| settings_menu.toggle()> <a class="nav-link dropdown-toggle hide-arrow" href="#" on:click=move |_| settings_menu.toggle()>
<i class="bx bx-cog fs-3 lh-0"></i> <i class="bx bx-cog fs-3 lh-0"></i>
+33 -31
View File
@@ -1,11 +1,12 @@
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter}; use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter};
use leptos::*; use leptos::prelude::*;
use leptos::server_fn::client::browser::BrowserClient; use leptos::form::{ActionForm, FromFormData};
use leptos::server_fn::codec::{Json, PostUrl}; use leptos::ev::SubmitEvent;
use leptos::server_fn::error::NoCustomError; use leptos::server_fn::client::Client;
use leptos::server_fn::ServerFn; use leptos::server_fn::codec::PostUrl;
use leptos_router::*; use leptos::server_fn::request::ClientReq;
use leptos::server_fn::{Http, ServerFn};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use validator::Validate; use validator::Validate;
use crate::components::server_err::ServerErr; use crate::components::server_err::ServerErr;
@@ -18,27 +19,28 @@ pub trait ForValidation {
} }
#[component] #[component]
pub fn data_form<T: 'static + Clone + pub fn data_form<T, O>(
ServerFn<Client = BrowserClient,
InputEncoding = PostUrl,
OutputEncoding = Json,
Error = NoCustomError,
Output = ApiResponse<()>> +
DeserializeOwned + ForValidation>(
opener: DialogOpener, opener: DialogOpener,
action: Action<T, Result<ApiResponse<()>, ServerFnError>>, action: ServerAction<T>,
title: &'static str, title: &'static str,
children: Children children: Children
) -> impl IntoView { ) -> impl IntoView
let upd_val = action.value(); where
T: ServerFn<Protocol = Http<PostUrl, O>, Output = ApiResponse<()>, Error = ServerFnError>
+ DeserializeOwned + ForValidation + Clone + Send + Sync + 'static,
T::Client: Client<ServerFnError>,
<<T::Client as Client<ServerFnError>>::Request as ClientReq<ServerFnError>>::FormData: From<web_sys::FormData>,
O: 'static
{
let upd_val = Signal::derive(move || action.value().get());
let validator = Validator::new(); let validator = Validator::new();
view! { view! {
<ActionForm <ActionForm
on:submit=move |ev| { // Capture phase, so the validation runs before the form is dispatched
let act = T::from_event(&ev); on:submit:capture=move |ev: SubmitEvent| {
if !act.is_err() { if let Ok(act) = T::from_event(&ev) {
validator.check(act.unwrap().entity(), &ev); validator.check(act.entity(), &ev);
} }
} }
action=action> action=action>
@@ -65,20 +67,20 @@ pub fn data_form<T: 'static + Clone +
} }
#[component] #[component]
pub fn question_dialog<T: 'static pub fn question_dialog<T, O>(
+ ServerFn<Client = BrowserClient,
InputEncoding = PostUrl,
OutputEncoding = Json,
Error = NoCustomError,
Output = ApiResponse<()>>
+ DeserializeOwned
+ Clone>(
opener: DialogOpener, opener: DialogOpener,
action: Action<T, Result<ApiResponse<()>, ServerFnError>>, action: ServerAction<T>,
title: &'static str, title: &'static str,
children: Children children: Children
) -> impl IntoView { ) -> impl IntoView
let upd_val = action.value(); where
T: ServerFn<Protocol = Http<PostUrl, O>, Output = ApiResponse<()>, Error = ServerFnError>
+ DeserializeOwned + Clone + Send + Sync + 'static,
T::Client: Client<ServerFnError>,
<<T::Client as Client<ServerFnError>>::Request as ClientReq<ServerFnError>>::FormData: From<web_sys::FormData>,
O: 'static
{
let upd_val = Signal::derive(move || action.value().get());
view! { view! {
<ActionForm action=action> <ActionForm action=action>
+15 -14
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use leptos_meta::*; use leptos_meta::*;
use crate::app::DialogHelper; use crate::app::DialogHelper;
use crate::backend::appearance::get_appearance; use crate::backend::appearance::get_appearance;
@@ -8,18 +8,18 @@ use crate::components::user_menu::MenuOpener;
pub fn Header() -> impl IntoView { pub fn Header() -> impl IntoView {
let drawer = use_context::<MenuOpener>().expect("No drawer opener"); let drawer = use_context::<MenuOpener>().expect("No drawer opener");
let dlg_helper = use_context::<DialogHelper>().expect("No dialog helper"); let dlg_helper = use_context::<DialogHelper>().expect("No dialog helper");
let appearance = create_blocking_resource(||(), |_| get_appearance()); let appearance = Resource::new_blocking(||(), |_| get_appearance());
view! { view! {
<Html <Html
{..}
lang="cz" lang="cz"
dir="ltr" dir="ltr"
class = {move || if drawer.visible() {"light-style layout-menu-fixed layout-menu-expanded"} class = {move || if drawer.visible() {"light-style layout-menu-fixed layout-menu-expanded"}
else {"light-style layout-menu-fixed"} } else {"light-style layout-menu-fixed"} }
attributes=vec![ data-theme="theme-default"
("data-theme", "theme-default".into_attribute()), data-template="vertical-menu-template-free"
("data-template", "vertical-menu-template-free".into_attribute()), data-assets-path="/"
("data-assets-path", "/".into_attribute())]
/> />
<Title text="Rezervovator"/> <Title text="Rezervovator"/>
<Meta charset="utf-8"/> <Meta charset="utf-8"/>
@@ -41,19 +41,20 @@ pub fn Header() -> impl IntoView {
<Link rel="stylesheet" href="/vendor/css/theme-default.css" /> <Link rel="stylesheet" href="/vendor/css/theme-default.css" />
<Link rel="stylesheet" href="/css/demo.css" /> <Link rel="stylesheet" href="/css/demo.css" />
<Transition fallback=move || view! {""}> <Transition fallback=move || view! {""}>
{ {move || {
appearance.get().map(|a| match a { appearance.get().map(|a| {
Ok(a) => view! {<Link rel="stylesheet" href={format!("/data/{}", a.css_name.unwrap_or_default())} />}, let css_name = match a {
Err(_) => view! {<Link rel="stylesheet" href="/data/banner.css" />} Ok(a) => a.css_name.unwrap_or_default(),
Err(_) => "banner.css".to_string()
};
view! {<Link rel="stylesheet" href={format!("/data/{}", css_name)} />}
}) })
} }}
</Transition> </Transition>
<Link rel="stylesheet" href="/vendor/css/control.css" /> <Link rel="stylesheet" href="/vendor/css/control.css" />
//<!-- Vendors CSS --> //<!-- Vendors CSS -->
<Link rel="stylesheet" href="/vendor/libs/perfect-scrollbar/perfect-scrollbar.css" /> <Link rel="stylesheet" href="/vendor/libs/perfect-scrollbar/perfect-scrollbar.css" />
<Body attributes=vec![ <Body {..} style={move || if dlg_helper.is_open() {"overflow: hidden;"} else {""}}/>
("style", {move || if dlg_helper.is_open() {"overflow: hidden;"} else {""}}.into_attribute())
]/>
} }
} }
+1
View File
@@ -5,4 +5,5 @@ pub mod header;
pub mod admin_portal; pub mod admin_portal;
pub mod user_menu; pub mod user_menu;
pub mod data_form; pub mod data_form;
pub mod theme;
+18 -13
View File
@@ -1,5 +1,5 @@
use crate::locales::trl; use crate::locales::trl;
use leptos::*; use leptos::prelude::*;
use crate::app::DialogHelper; use crate::app::DialogHelper;
use crate::validator::Validator; use crate::validator::Validator;
@@ -11,15 +11,17 @@ pub struct DialogOpener {
set_empty: WriteSignal<String>, set_empty: WriteSignal<String>,
not_checked: ReadSignal<Option<String>>, not_checked: ReadSignal<Option<String>>,
set_not_checked: WriteSignal<Option<String>>, set_not_checked: WriteSignal<Option<String>>,
show_err: RwSignal<bool> show_err: RwSignal<bool>,
// Context is not available in event handlers, so the helper is looked up when the opener is created
dlg_helper: Option<DialogHelper>
} }
impl DialogOpener { impl DialogOpener {
pub fn new() -> Self { pub fn new() -> Self {
let (visible, set_visible) = create_signal(false); let (visible, set_visible) = signal(false);
let (empty, set_empty) = create_signal("".to_string()); let (empty, set_empty) = signal("".to_string());
let (not_checked, set_not_checked) = create_signal(None); let (not_checked, set_not_checked) = signal(None);
let show_err = create_rw_signal(false); let show_err = RwSignal::new(false);
DialogOpener { DialogOpener {
visible, visible,
set_visible, set_visible,
@@ -27,7 +29,8 @@ impl DialogOpener {
set_empty, set_empty,
not_checked, not_checked,
set_not_checked, set_not_checked,
show_err show_err,
dlg_helper: use_context::<DialogHelper>()
} }
} }
@@ -36,15 +39,17 @@ impl DialogOpener {
} }
pub fn show(&self) { pub fn show(&self) {
let dlg_helper = use_context::<DialogHelper>().expect("No dialog helper"); if let Some(dlg_helper) = self.dlg_helper {
dlg_helper.set_opened(true); dlg_helper.set_opened(true);
self.set_visible.update(|state| *state = true); }
self.set_visible.set(true);
} }
pub fn hide(&self) { pub fn hide(&self) {
let dlg_helper = use_context::<DialogHelper>().expect("No dialog helper"); if let Some(dlg_helper) = self.dlg_helper {
dlg_helper.set_opened(false); dlg_helper.set_opened(false);
self.set_visible.update(|state| *state = false); }
self.set_visible.set(false);
self.set_empty.set("".to_string()); self.set_empty.set("".to_string());
self.set_not_checked.set(None); self.set_not_checked.set(None);
self.show_err.set(false); self.show_err.set(false);
+7 -6
View File
@@ -1,11 +1,12 @@
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use leptos::*; use leptos::prelude::*;
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::locales::trl; use crate::locales::trl;
#[component] #[component]
pub fn ServerErr( pub fn ServerErr(
result: RwSignal<Option<Result<ApiResponse<()>, ServerFnError>>>, #[prop(into)]
result: Signal<Option<Result<ApiResponse<()>, ServerFnError>>>,
opener: DialogOpener, opener: DialogOpener,
) -> impl IntoView { ) -> impl IntoView {
view! {{move || { view! {{move || {
@@ -23,22 +24,22 @@ pub fn ServerErr(
}}> }}>
{trl(&err)} {trl(&err)}
</div> </div>
} }.into_any()
} }
else { else {
opener.hide(); opener.hide();
view! {<div></div>} view! {<div></div>}.into_any()
} }
Err(e) => { Err(e) => {
view! { view! {
<div class="alert alert-danger"> <div class="alert alert-danger">
"Server error: " {e.to_string()} "Server error: " {e.to_string()}
</div> </div>
} }.into_any()
} }
} }
} else { } else {
view! {<div></div>} view! {<div></div>}.into_any()
} }
}} }}
} }
+168
View File
@@ -0,0 +1,168 @@
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use codee::string::FromToStringCodec;
use leptos::prelude::*;
use leptos_use::{use_cookie_with_options, use_preferred_dark, SameSite, UseCookieOptions};
const THEME_COOKIE: &str = "theme";
const YEAR_MS: i64 = 365 * 24 * 3600 * 1000;
/// Colour theme of the admin portal chosen by the user
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ThemeMode {
/// Follow the operating system setting
#[default]
Auto,
Light,
Dark
}
impl ThemeMode {
/// Next mode in the order of the switch: auto -> light -> dark -> auto
pub fn next(&self) -> Self {
match self {
ThemeMode::Auto => ThemeMode::Light,
ThemeMode::Light => ThemeMode::Dark,
ThemeMode::Dark => ThemeMode::Auto
}
}
/// Media query for the dark stylesheet link
pub fn media(&self) -> &'static str {
match self {
ThemeMode::Auto => "(prefers-color-scheme: dark)",
ThemeMode::Light => "not all",
ThemeMode::Dark => "all"
}
}
pub fn icon(&self) -> &'static str {
match self {
ThemeMode::Auto => "bx bx-adjust fs-3 lh-0",
ThemeMode::Light => "bx bx-sun fs-3 lh-0",
ThemeMode::Dark => "bx bx-moon fs-3 lh-0"
}
}
/// Translation key of the mode description
pub fn label(&self) -> &'static str {
match self {
ThemeMode::Auto => "Theme: by system",
ThemeMode::Light => "Theme: light",
ThemeMode::Dark => "Theme: dark"
}
}
pub fn is_dark(&self, system_dark: bool) -> bool {
match self {
ThemeMode::Auto => system_dark,
ThemeMode::Light => false,
ThemeMode::Dark => true
}
}
}
impl Display for ThemeMode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", match self {
ThemeMode::Auto => "auto",
ThemeMode::Light => "light",
ThemeMode::Dark => "dark"
})
}
}
impl FromStr for ThemeMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"auto" => Ok(ThemeMode::Auto),
"light" => Ok(ThemeMode::Light),
"dark" => Ok(ThemeMode::Dark),
_ => Err(())
}
}
}
/// Theme of the admin portal. The choice is kept in a cookie, so the server renders the same theme as the browser.
#[derive(Clone, Copy)]
pub struct Theme {
cookie: Signal<Option<String>>,
set_cookie: WriteSignal<Option<String>>,
system_dark: Signal<bool>
}
impl Theme {
pub fn new() -> Self {
let (cookie, set_cookie) = use_cookie_with_options::<String, FromToStringCodec>(
THEME_COOKIE,
UseCookieOptions::default()
.max_age(YEAR_MS)
.path("/")
.same_site(SameSite::Lax));
Self {
cookie,
set_cookie,
system_dark: use_preferred_dark()
}
}
pub fn mode(&self) -> ThemeMode {
self.cookie.get().and_then(|m| m.parse().ok()).unwrap_or_default()
}
/// Switch to the next mode
pub fn cycle(&self) {
let next = self.cookie.get_untracked().and_then(|m| m.parse::<ThemeMode>().ok()).unwrap_or_default().next();
self.set_cookie.set(Some(next.to_string()));
}
/// Is the dark theme displayed right now? The server can't know the system setting, there it is false in auto mode.
pub fn is_dark(&self) -> bool {
self.mode().is_dark(self.system_dark.get())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_cycles_through_all_states() {
assert_eq!(ThemeMode::Auto.next(), ThemeMode::Light);
assert_eq!(ThemeMode::Light.next(), ThemeMode::Dark);
assert_eq!(ThemeMode::Dark.next(), ThemeMode::Auto);
}
#[test]
fn mode_string_round_trip() {
for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] {
assert_eq!(mode.to_string().parse::<ThemeMode>(), Ok(mode));
}
}
#[test]
fn unknown_cookie_value_is_rejected() {
assert!("".parse::<ThemeMode>().is_err());
assert!("Dark".parse::<ThemeMode>().is_err());
assert!("blue".parse::<ThemeMode>().is_err());
assert_eq!(ThemeMode::default(), ThemeMode::Auto);
}
#[test]
fn media_query_for_dark_stylesheet() {
assert_eq!(ThemeMode::Auto.media(), "(prefers-color-scheme: dark)");
assert_eq!(ThemeMode::Light.media(), "not all");
assert_eq!(ThemeMode::Dark.media(), "all");
}
#[test]
fn dark_follows_system_only_in_auto_mode() {
assert!(ThemeMode::Auto.is_dark(true));
assert!(!ThemeMode::Auto.is_dark(false));
assert!(!ThemeMode::Light.is_dark(true));
assert!(ThemeMode::Dark.is_dark(false));
}
}
+21 -16
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::app::MenuHelper; use crate::app::MenuHelper;
use crate::backend::data::User; use crate::backend::data::User;
use crate::backend::user::{get_user, logout}; use crate::backend::user::{get_user, logout};
@@ -9,14 +9,17 @@ use crate::locales::trl;
pub struct MenuOpener { pub struct MenuOpener {
visible: ReadSignal<bool>, visible: ReadSignal<bool>,
set_visible: WriteSignal<bool>, set_visible: WriteSignal<bool>,
// Context is not available in event handlers, so the helper is looked up when the opener is created
helper: Option<MenuHelper>,
} }
impl MenuOpener { impl MenuOpener {
pub fn new() -> Self { pub fn new() -> Self {
let (visible, set_visible) = create_signal(false); let (visible, set_visible) = signal(false);
MenuOpener { MenuOpener {
visible, visible,
set_visible, set_visible,
helper: use_context::<MenuHelper>(),
} }
} }
@@ -25,22 +28,24 @@ impl MenuOpener {
} }
pub fn toggle(self) { pub fn toggle(self) {
let visible = self.visible.get(); let visible = self.visible.get_untracked();
self.set_visible.update(|v| *v = !visible); self.set_visible.set(!visible);
let helper = use_context::<MenuHelper>().expect("No menu helper"); if let Some(helper) = self.helper {
if !visible { if !visible {
helper.close(); helper.close();
helper.set_opened(self); helper.set_opened(self);
} else { } else {
helper.reset(); helper.reset();
}
} }
} }
pub fn close(&self) { pub fn close(&self) {
self.set_visible.set(false); self.set_visible.set(false);
let helper = use_context::<MenuHelper>().expect("No menu helper"); if let Some(helper) = self.helper {
helper.reset(); helper.reset();
}
} }
} }
@@ -50,7 +55,7 @@ pub fn UserMenu(
editor: DialogOpener, editor: DialogOpener,
pw_dialog: DialogOpener, pw_dialog: DialogOpener,
user_profile: WriteSignal<User>) -> impl IntoView { user_profile: WriteSignal<User>) -> impl IntoView {
let user = create_resource(move || opener.visible(), move |_| get_user()); let user = Resource::new(move || opener.visible(), move |_| get_user());
view! { view! {
<ul <ul
@@ -84,13 +89,13 @@ pub fn UserMenu(
<span class="fw-semibold d-block"> <span class="fw-semibold d-block">
{usr.full_name} {usr.full_name}
</span> </span>
} }.into_any()
} }
Err(_) => { Err(_) => {
view! { view! {
// <small class="text-muted">"Admin"</small> // <small class="text-muted">"Admin"</small>
<span>"Error loading user"</span> <span>"Error loading user"</span>
} }.into_any()
} }
}) })
}} }}
@@ -114,7 +119,7 @@ pub fn UserMenu(
class="dropdown-item" class="dropdown-item"
href="/login" href="/login"
on:click=move |_| { on:click=move |_| {
spawn_local(async move { leptos::task::spawn_local(async move {
let _ = logout().await; let _ = logout().await;
}); });
} }
+4 -4
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::locales::trl; use crate::locales::trl;
use crate::validator::Validator; use crate::validator::Validator;
@@ -20,16 +20,16 @@ pub fn ValidationErr(
<div> <div>
{out_msgs} {out_msgs}
</div> </div>
} }.into_any()
} else { } else {
view! { view! {
<div class="alert alert-danger"> <div class="alert alert-danger">
{trl("Validation error")} {trl("Validation error")}
</div> </div>
} }.into_any()
} }
} else { } else {
view! {<div></div>} view! {<div></div>}.into_any()
} }
}} }}
} }
+198 -2
View File
@@ -3,6 +3,8 @@ use std::fs::read_to_string;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::net::SocketAddr; use std::net::SocketAddr;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::path::{Path, PathBuf};
#[cfg(feature = "ssr")]
use std::str::FromStr; use std::str::FromStr;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use serde::Deserialize; use serde::Deserialize;
@@ -29,6 +31,10 @@ impl Session {
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
impl Network { impl Network {
pub fn port(&self) -> u16 {
self.port
}
pub fn bind_address(&self) -> SocketAddr { pub fn bind_address(&self) -> SocketAddr {
SocketAddr::from_str(format!("{}:{}", &self.bind_ip, self.port).as_str()).expect("Can't parse bind IP address") SocketAddr::from_str(format!("{}:{}", &self.bind_ip, self.port).as_str()).expect("Can't parse bind IP address")
} }
@@ -41,11 +47,18 @@ pub struct Database {
password: String, password: String,
db_name: String, db_name: String,
host: String, host: String,
port: Option<u16> port: Option<u16>,
max_connections: Option<u32>
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
impl Database { impl Database {
/// Upper limit of open database connections, 10 by default. With many instances on one
/// PostgreSQL server the sum of the limits must fit into its `max_connections`.
pub fn max_connections(&self) -> u32 {
self.max_connections.unwrap_or(10).max(1)
}
pub fn con_string(&self) -> String { pub fn con_string(&self) -> String {
format!("postgres://{}:{}@{}:{}/{}", self.user, self.password, self.host, self.port.unwrap_or(5432), self.db_name) format!("postgres://{}:{}@{}:{}/{}", self.user, self.password, self.host, self.port.unwrap_or(5432), self.db_name)
} }
@@ -120,6 +133,57 @@ impl Logging {
} }
} }
/// Where files created at runtime (uploaded banner, generated CSS) are kept
#[cfg(feature = "ssr")]
#[derive(Deserialize, Default)]
pub struct Storage {
data_dir: Option<String>
}
#[cfg(feature = "ssr")]
impl Storage {
/// Directory served under `/data`. Defaults to `data` in the working directory.
pub fn data_dir(&self) -> PathBuf {
PathBuf::from(self.data_dir.as_deref().unwrap_or("data"))
}
}
/// Environment variable with the password of the first admin account. Takes precedence over the config file.
#[cfg(feature = "ssr")]
pub const ADMIN_PASSWORD_ENV: &str = "REZERVATOR_ADMIN_PASSWORD";
#[cfg(feature = "ssr")]
pub const ADMIN_PASSWORD_MIN_LEN: usize = 8;
/// Setup of the first admin account
#[cfg(feature = "ssr")]
#[derive(Deserialize, Default)]
pub struct Admin {
initial_password: Option<String>
}
#[cfg(feature = "ssr")]
impl Admin {
/// Password for the admin account created on first start: the environment variable, or the
/// `initial_password` config key. There is no default, a known password must never be used.
pub fn initial_password(&self) -> Result<String, String> {
resolve_initial_password(std::env::var(ADMIN_PASSWORD_ENV).ok(), self.initial_password.clone())
}
}
#[cfg(feature = "ssr")]
fn resolve_initial_password(env: Option<String>, config: Option<String>) -> Result<String, String> {
let password = env.filter(|p| !p.is_empty()).or(config.filter(|p| !p.is_empty()))
.ok_or(format!(
"No admin account exists and no initial password is set. Set the {} environment variable or initial_password in the [admin] section of the config.",
ADMIN_PASSWORD_ENV))?;
if password.chars().count() < ADMIN_PASSWORD_MIN_LEN {
return Err(format!("Initial admin password must have at least {} characters.", ADMIN_PASSWORD_MIN_LEN))
}
Ok(password)
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Configuration { pub struct Configuration {
@@ -127,7 +191,11 @@ pub struct Configuration {
network: Network, network: Network,
database: Database, database: Database,
mailing: Mailing, mailing: Mailing,
logging: Logging logging: Logging,
#[serde(default)]
storage: Storage,
#[serde(default)]
admin: Admin
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -147,6 +215,22 @@ impl Configuration {
pub fn logging(&self) -> &Logging { pub fn logging(&self) -> &Logging {
&self.logging &self.logging
} }
pub fn storage(&self) -> &Storage {
&self.storage
}
pub fn admin(&self) -> &Admin {
&self.admin
}
}
/// The config file holds passwords and the session key, so it must never lie in a directory
/// that is served as static files. Returns true when `config_path` is inside `public_dir`.
#[cfg(feature = "ssr")]
pub fn is_publicly_served(config_path: &Path, public_dir: &Path) -> bool {
match (config_path.canonicalize(), public_dir.canonicalize()) {
(Ok(config), Ok(root)) => config.starts_with(root),
_ => false
}
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -177,6 +261,92 @@ mod tests {
assert_eq!(config.mailing().server(), &None); assert_eq!(config.mailing().server(), &None);
} }
/// Creates `<temp>/<name>/site/data` and returns the `<temp>/<name>` directory
fn site_dir(name: &str) -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!("rezervator-test-{}-{}", name, std::process::id()));
std::fs::create_dir_all(base.join("site/data")).unwrap();
base
}
#[test]
fn config_inside_site_root_is_publicly_served() {
let base = site_dir("inside");
let config = base.join("site/data/config.toml");
std::fs::write(&config, "").unwrap();
assert!(is_publicly_served(&config, &base.join("site")));
// Relative segments must not hide it
assert!(is_publicly_served(&base.join("site/data/../data/config.toml"), &base.join("site")));
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn config_outside_site_root_is_not_publicly_served() {
let base = site_dir("outside");
let config = base.join("config.toml");
std::fs::write(&config, "").unwrap();
assert!(!is_publicly_served(&config, &base.join("site")));
// A sibling directory with the same prefix is not inside the site root
std::fs::create_dir_all(base.join("site-config")).unwrap();
std::fs::write(base.join("site-config/config.toml"), "").unwrap();
assert!(!is_publicly_served(&base.join("site-config/config.toml"), &base.join("site")));
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn initial_password_is_required() {
assert!(resolve_initial_password(None, None).is_err());
assert!(resolve_initial_password(Some("".to_string()), Some("".to_string())).is_err());
// The sample config must not ship a usable password
assert!(resolve_initial_password(None, sample().admin.initial_password).is_err());
}
#[test]
fn initial_password_from_config() {
let admin: Admin = toml::from_str("initial_password = 'correct horse'").unwrap();
assert_eq!(resolve_initial_password(None, admin.initial_password), Ok("correct horse".to_string()));
}
#[test]
fn initial_password_env_takes_precedence() {
assert_eq!(
resolve_initial_password(Some("from-environment".to_string()), Some("from-config".to_string())),
Ok("from-environment".to_string()));
// An empty variable does not hide the config value
assert_eq!(
resolve_initial_password(Some("".to_string()), Some("from-config".to_string())),
Ok("from-config".to_string()));
}
#[test]
fn initial_password_must_not_be_short() {
assert!(resolve_initial_password(Some("admin".to_string()), None).is_err());
assert!(resolve_initial_password(None, Some("1234567".to_string())).is_err());
assert!(resolve_initial_password(None, Some("12345678".to_string())).is_ok());
// Length is counted in characters, not bytes
assert!(resolve_initial_password(None, Some("čšřžýáí".to_string())).is_err());
}
#[test]
fn data_dir_defaults_to_data() {
// The sample config has the storage section commented out
assert_eq!(sample().storage().data_dir(), PathBuf::from("data"));
let storage: Storage = toml::from_str("").unwrap();
assert_eq!(storage.data_dir(), PathBuf::from("data"));
}
#[test]
fn data_dir_can_be_configured() {
let storage: Storage = toml::from_str("data_dir = '/var/lib/rezervator'").unwrap();
assert_eq!(storage.data_dir(), PathBuf::from("/var/lib/rezervator"));
}
#[test] #[test]
fn sample_session_key_is_long_enough() { fn sample_session_key_is_long_enough() {
// actix_web::cookie::Key::from panics on keys shorter than 64 bytes // actix_web::cookie::Key::from panics on keys shorter than 64 bytes
@@ -199,6 +369,32 @@ mod tests {
assert_eq!(database.con_string(), "postgres://u:[email protected]:6543/db"); assert_eq!(database.con_string(), "postgres://u:[email protected]:6543/db");
} }
#[test]
fn max_connections_defaults_to_ten() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'localhost'").unwrap();
assert_eq!(database.max_connections(), 10);
// The sample config has the key commented out
assert_eq!(sample().database().max_connections(), 10);
}
#[test]
fn max_connections_can_be_configured() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'localhost'\nmax_connections = 3").unwrap();
assert_eq!(database.max_connections(), 3);
}
#[test]
fn max_connections_is_at_least_one() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'localhost'\nmax_connections = 0").unwrap();
assert_eq!(database.max_connections(), 1);
}
#[test] #[test]
fn smtp_mailing_config() { fn smtp_mailing_config() {
let mailing: Mailing = toml::from_str( let mailing: Mailing = toml::from_str(
+1 -1
View File
@@ -1,7 +1,7 @@
use std::error::Error; use std::error::Error;
use std::fmt::{Debug, Display, Formatter}; use std::fmt::{Debug, Display, Formatter};
use chrono::ParseError; use chrono::ParseError;
use leptos::ServerFnError; use leptos::prelude::ServerFnError;
use log::error; use log::error;
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
+9 -17
View File
@@ -1,3 +1,6 @@
// Leptos view types are deeply nested; release builds overflow the default limit
#![recursion_limit = "512"]
pub mod app; pub mod app;
pub mod locales; pub mod locales;
pub mod backend; pub mod backend;
@@ -7,23 +10,12 @@ mod validator;
pub mod error; pub mod error;
pub mod config; pub mod config;
use cfg_if::cfg_if; #[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::*;
cfg_if! { console_error_panic_hook::set_once();
if #[cfg(feature = "hydrate")] {
use wasm_bindgen::prelude::wasm_bindgen; leptos::mount::hydrate_body(App);
#[wasm_bindgen]
pub fn hydrate() {
use app::*;
use leptos::*;
console_error_panic_hook::set_once();
leptos::mount_to_body(move || {
view! { <App/> }
});
}
}
} }
+9 -6
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use leptos::*; use leptos::prelude::*;
use crate::locales::Locales; use crate::locales::Locales;
lazy_static! { lazy_static! {
@@ -103,6 +103,10 @@ lazy_static! {
("Date can't be in past", "Datum nesmí být v minulosti"), ("Date can't be in past", "Datum nesmí být v minulosti"),
("Enter your username", "Zadejte své uživatelské jméno"), ("Enter your username", "Zadejte své uživatelské jméno"),
("Hours must be in HH:MM - HH:MM format", "Hodiny musí být zadány ve tvaru HH:MM - HH:MM"), ("Hours must be in HH:MM - HH:MM format", "Hodiny musí být zadány ve tvaru HH:MM - HH:MM"),
("Time 'to' must be after time 'from'", "Čas 'do' musí být po čase 'od'"),
("Theme: by system", "Vzhled: podle systému"),
("Theme: light", "Vzhled: světlý"),
("Theme: dark", "Vzhled: tmavý"),
("Edit profile", "Upravit profil"), ("Edit profile", "Upravit profil"),
("Get emails", "Dostávat e-maily"), ("Get emails", "Dostávat e-maily"),
("Enter username", "Zadejte uživatelské jméno"), ("Enter username", "Zadejte uživatelské jméno"),
@@ -181,16 +185,15 @@ lazy_static! {
}; };
} }
fn get_locales() -> Vec<Option<String>> { pub fn get_locales() -> Vec<Option<String>> {
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]
{ {
use_context::<Locales>().unwrap_or(Locales(vec![])).0 // Context is not available in event handlers, ask the browser there
use_context::<Locales>().unwrap_or_else(crate::locales::browser_locales).0
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
{ {
use actix_web::HttpRequest; let req = use_context::<leptos_actix::Request>();
let req = use_context::<HttpRequest>();
if let Some(r) = req { if let Some(r) = req {
if let Some(al) = r.head().headers.get("accept-language") { if let Some(al) = r.head().headers.get("accept-language") {
Locales(al.to_str().unwrap_or("").split(",").map(|l| Some(l.to_string())).collect()).0 Locales(al.to_str().unwrap_or("").split(",").map(|l| Some(l.to_string())).collect()).0
+18 -18
View File
@@ -1,6 +1,6 @@
use chrono::{NaiveDate, Weekday}; use chrono::{NaiveDate, Weekday};
use leptos::use_context; use leptos::prelude::*;
use crate::locales::catalogues::get_dictionary; use crate::locales::catalogues::{get_dictionary, get_locales};
mod catalogues; mod catalogues;
@@ -10,24 +10,12 @@ pub struct Locales(pub Vec<Option<String>>);
pub fn init_locales() { pub fn init_locales() {
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]
{ {
use leptos::*; provide_context(browser_locales());
let loc = Locales(
window()
.navigator()
.languages()
.into_iter()
.map(|val| val.as_string())
.collect::<Vec<_>>(),
);
provide_context(loc);
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
{ {
use actix_web::HttpRequest; let req = use_context::<leptos_actix::Request>();
use leptos::provide_context;
let req = use_context::<HttpRequest>();
if let Some(r) = req { if let Some(r) = req {
if r.query_string().contains("lang") { if r.query_string().contains("lang") {
let languages = r.query_string() let languages = r.query_string()
@@ -49,6 +37,19 @@ pub fn init_locales() {
} }
} }
/// Languages preferred by the browser
#[cfg(not(feature = "ssr"))]
pub fn browser_locales() -> Locales {
Locales(
window()
.navigator()
.languages()
.into_iter()
.map(|val| val.as_string())
.collect::<Vec<_>>(),
)
}
pub fn trl(phrase: &str) -> impl Fn() -> String { pub fn trl(phrase: &str) -> impl Fn() -> String {
let mut translated = phrase; let mut translated = phrase;
if let Some(dict) = get_dictionary() { if let Some(dict) = get_dictionary() {
@@ -65,8 +66,7 @@ pub fn trl(phrase: &str) -> impl Fn() -> String {
// ToDo better date formatting // ToDo better date formatting
pub fn loc_date(date: NaiveDate) -> impl Fn() -> String { pub fn loc_date(date: NaiveDate) -> impl Fn() -> String {
let mut dt = date.format("%Y-%m-%d").to_string(); let mut dt = date.format("%Y-%m-%d").to_string();
let locs = use_context::<Locales>().unwrap_or(Locales(vec![])).0; for loc in get_locales() {
for loc in locs {
if let Some(key) = loc { if let Some(key) = loc {
if let Some(k) = key.split("-").collect::<Vec<_>>().get(0) { if let Some(k) = key.split("-").collect::<Vec<_>>().get(0) {
if *k != "en" { if *k != "en" {
+75 -29
View File
@@ -1,7 +1,10 @@
// Leptos view types are deeply nested; release builds overflow the default limit
#![recursion_limit = "512"]
use actix_web::middleware::Logger; use actix_web::middleware::Logger;
use leptos_captcha::spow::pow::Pow; use leptos_captcha::spow::pow::Pow;
use log::error; use log::error;
use rezervator::backend::appearance::check_appearance; use rezervator::backend::appearance::{check_appearance, ensure_data_dir};
use rezervator::backend::company::check_company; use rezervator::backend::company::check_company;
use rezervator::backend::mail::{check_messages, Mailer}; use rezervator::backend::mail::{check_messages, Mailer};
use rezervator::backend::user::create_admin; use rezervator::backend::user::create_admin;
@@ -11,7 +14,9 @@ use rezervator::backend::user::create_admin;
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
use actix_files::Files; use actix_files::Files;
use actix_web::*; use actix_web::*;
use leptos::*; use leptos::prelude::*;
use leptos::config::get_configuration;
use leptos_meta::MetaTags;
use leptos_actix::{generate_route_list, LeptosRoutes}; use leptos_actix::{generate_route_list, LeptosRoutes};
use rezervator::app::*; use rezervator::app::*;
use rezervator::backend::*; use rezervator::backend::*;
@@ -22,7 +27,9 @@ async fn main() -> std::io::Result<()> {
use sqlx::migrate; use sqlx::migrate;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use rezervator::backend::auth_middleware::Authentication; use rezervator::backend::auth_middleware::Authentication;
use rezervator::config::load_config; use rezervator::config::{is_publicly_served, load_config};
use rezervator::backend::health::{health, is_healthy};
use std::path::Path;
use env_logger::Env; use env_logger::Env;
use std::env::args; use std::env::args;
use getopts::Options; use getopts::Options;
@@ -32,6 +39,7 @@ async fn main() -> std::io::Result<()> {
let mut opts = Options::new(); let mut opts = Options::new();
opts.optopt("c", "config", "Path to config file. Default is ./config.toml", ""); opts.optopt("c", "config", "Path to config file. Default is ./config.toml", "");
opts.optflag("h", "help", "Show help"); opts.optflag("h", "help", "Show help");
opts.optflag("", "healthcheck", "Check health of the running server and exit with 0 (healthy) or 1");
let matches = opts.parse(&args[1..]).expect("Can't parse commandline"); let matches = opts.parse(&args[1..]).expect("Can't parse commandline");
if matches.opt_present("h") { if matches.opt_present("h") {
@@ -40,29 +48,53 @@ async fn main() -> std::io::Result<()> {
return Ok(()); return Ok(());
} }
Pow::init_random().expect("Cannot init captcha");
let cfg_path = matches.opt_str("c").unwrap_or("config.toml".to_string()); let cfg_path = matches.opt_str("c").unwrap_or("config.toml".to_string());
let srv_conf = load_config(cfg_path.trim()); let srv_conf = load_config(cfg_path.trim());
// Used as the container health check, see Dockerfile
if matches.opt_present("healthcheck") {
std::process::exit(if is_healthy(srv_conf.network().port()) { 0 } else { 1 });
}
Pow::init_random().expect("Cannot init captcha");
env_logger::Builder::from_env(Env::default().default_filter_or(srv_conf.logging().severity())).init(); env_logger::Builder::from_env(Env::default().default_filter_or(srv_conf.logging().severity())).init();
info!("Starting server"); info!("Starting server");
let conf = get_configuration(None).await.unwrap(); let conf = get_configuration(None).unwrap();
// Generate the list of routes in your Leptos App
let routes = generate_route_list(|| view! { <App/> }); // Files created at runtime (banner, generated styles) live outside the site root, so they survive upgrades
let data_dir = srv_conf.storage().data_dir();
if let Err(e) = ensure_data_dir(&data_dir, Path::new(&*conf.leptos_options.site_root)) {
error!("Cannot prepare data directory {}: {}", data_dir.display(), e);
return Err(std::io::Error::other(e));
}
// The site root and the data directory are served as static files, the config with passwords must not be there
for public_dir in [Path::new(&*conf.leptos_options.site_root), data_dir.as_path()] {
if is_publicly_served(Path::new(cfg_path.trim()), public_dir) {
let msg = format!(
"Config file {} is inside the publicly served directory {}. Move it elsewhere and change the passwords and the session key in it.",
cfg_path.trim(), public_dir.display());
error!("{}", msg);
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg));
}
}
let key = Key::from(srv_conf.session().key()); let key = Key::from(srv_conf.session().key());
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.max_connections(10) .max_connections(srv_conf.database().max_connections())
.connect(&srv_conf.database().con_string()).await.unwrap(); .connect(&srv_conf.database().con_string()).await.unwrap();
migrate!().run(&pool).await.expect("could not run SQLx migrations"); migrate!().run(&pool).await.expect("could not run SQLx migrations");
let mut mailer = Mailer::new(srv_conf.mailing()); let mut mailer = Mailer::new(srv_conf.mailing());
mailer.start_sender(); mailer.start_sender();
if let Err(e) = create_admin(&pool).await { // Without an admin account nobody can log in, and it must not get a known password
error!("Error while checking admin user: {:?}", e); if let Err(e) = create_admin(&pool, srv_conf.admin()).await {
error!("Cannot set up admin account: {}", e);
return Err(std::io::Error::other(e));
} }
if let Err(e) = check_company(&pool).await { if let Err(e) = check_company(&pool).await {
error!("Error while checking company: {:?}", e); error!("Error while checking company: {:?}", e);
@@ -70,29 +102,49 @@ async fn main() -> std::io::Result<()> {
if let Err(e) = check_messages(&pool).await { if let Err(e) = check_messages(&pool).await {
error!("Error while checking messages: {:?}", e); error!("Error while checking messages: {:?}", e);
} }
if let Err(e) = check_appearance(&pool).await { if let Err(e) = check_appearance(&pool, &data_dir).await {
error!("Error while checking messages: {:?}", e); error!("Error while checking messages: {:?}", e);
} }
HttpServer::new(move || { HttpServer::new(move || {
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
let leptos_options = &conf.leptos_options; let leptos_options = &conf.leptos_options;
let site_root = &leptos_options.site_root; let site_root = leptos_options.site_root.clone().to_string();
App::new() App::new()
.app_data(Data::new(AppData::new(pool.clone(), mailer.clone()))) .app_data(Data::new(AppData::new(pool.clone(), mailer.clone(), data_dir.clone())))
.wrap(Logger::default()) // The health check is called every few seconds, keep it out of the access log
.wrap(Logger::default().exclude("/health"))
.wrap(Authentication) .wrap(Authentication)
.wrap(SessionMiddleware::new( .wrap(SessionMiddleware::new(
CookieSessionStore::default(), CookieSessionStore::default(),
key.clone() key.clone()
)) ))
.route("/api/{tail:.*}", leptos_actix::handle_server_fns()) // Server functions under /api are registered by leptos_routes
.leptos_routes( .leptos_routes(routes, {
leptos_options.to_owned(), let leptos_options = leptos_options.clone();
routes.to_owned(), move || {
|| view! { <App/> }, view! {
) <!DOCTYPE html>
<html>
<head>
<AutoReload options=leptos_options.clone()/>
<HydrationScripts options=leptos_options.clone()/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
})
.app_data(Data::new(leptos_options.to_owned()))
.service(appearance::upload) .service(appearance::upload)
.service(health)
// Runtime files first, the site root would shadow them
.service(Files::new("/data", data_dir.clone()))
.service(Files::new("/", site_root)) .service(Files::new("/", site_root))
.wrap(middleware::Compress::default()) .wrap(middleware::Compress::default())
}) })
@@ -114,15 +166,9 @@ pub fn main() {
// a client-side main function is required for using `trunk serve` // a client-side main function is required for using `trunk serve`
// prefer using `cargo leptos serve` instead // prefer using `cargo leptos serve` instead
// to run: `trunk serve --open --features ssg` // to run: `trunk serve --open --features ssg`
use leptos::*; use rezervator::app::*;
use leptos_start::app::*;
use wasm_bindgen::prelude::wasm_bindgen;
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
leptos::mount_to_body(move || { leptos::mount::mount_to_body(App);
// note: for testing it may be preferrable to replace this with a
// more specific component, although leptos_router should still work
view! {<App/> }
});
} }
+32 -36
View File
@@ -1,8 +1,9 @@
use chrono::{Datelike, Local}; use chrono::{Datelike, Local};
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ApiResponse, ReservationState, ResSumWithItems}; use crate::backend::data::{ApiResponse, ReservationState, ResSumWithItems};
use crate::backend::reservation::{month_chart, reservations_in_month, year_chart, years}; use crate::backend::reservation::{month_chart, reservations_in_month, year_chart, years};
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog}; use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
use crate::components::theme::Theme;
use crate::components::user_menu::MenuOpener; use crate::components::user_menu::MenuOpener;
use crate::locales::{loc_date, show_day, trl}; use crate::locales::{loc_date, show_day, trl};
use crate::pages::new_reservations::CancelDialog; use crate::pages::new_reservations::CancelDialog;
@@ -11,7 +12,7 @@ use crate::pages::new_reservations::CancelDialog;
fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView { fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
let menu = MenuOpener::new(); let menu = MenuOpener::new();
let cancel_opener = DialogOpener::new(); let cancel_opener = DialogOpener::new();
let menu_visible = create_rw_signal(false); let menu_visible = RwSignal::new(false);
view! { view! {
<CancelDialog opener=cancel_opener reservation=reservation/> <CancelDialog opener=cancel_opener reservation=reservation/>
<ModalDialog title="Booking detail" opener=opener> <ModalDialog title="Booking detail" opener=opener>
@@ -27,7 +28,7 @@ fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
let:item> let:item>
{item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/> {item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/>
</For> </For>
{trl("Customer: ")}{detail.customer.full_name}", "<a href={format!("mailto:{}", detail.customer.email)}>{detail.customer.email}</a>", "{detail.customer.phone}<br/> {trl("Customer: ")}{detail.customer.full_name}", "<a href={format!("mailto:{}", detail.customer.email)}>{detail.customer.email.clone()}</a>", "{detail.customer.phone}<br/>
{ {
let note = detail.summary.note.clone(); let note = detail.summary.note.clone();
let show = note.is_some() && !note.clone().unwrap().is_empty(); let show = note.is_some() && !note.clone().unwrap().is_empty();
@@ -42,7 +43,7 @@ fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
}} }}
</p> </p>
<Show when=move || menu_visible.get()> <Show when=move || menu_visible.get()>
<table width="100%"> <table style="width: 100%"><tbody>
<tr> <tr>
<td width="100%"></td> <td width="100%"></td>
<td> <td>
@@ -62,7 +63,7 @@ fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
</div> </div>
</td> </td>
</tr> </tr>
</table> </tbody></table>
</Show> </Show>
</ModalBody> </ModalBody>
</ModalDialog> </ModalDialog>
@@ -71,20 +72,21 @@ fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
#[component] #[component]
pub fn bookings() -> impl IntoView { pub fn bookings() -> impl IntoView {
let year_chart = create_blocking_resource(||(), |_| year_chart()); let theme = use_context::<Theme>().expect("No theme");
let years = create_blocking_resource(||(), |_| years()); let year_chart = Resource::new_blocking(move || theme.is_dark(), |dark| year_chart(dark));
let year = create_rw_signal(Local::now().year()); let years = Resource::new_blocking(||(), |_| years());
let month = create_rw_signal(Local::now().month()); let year = RwSignal::new(Local::now().year());
let chart = create_blocking_resource(move || year.get(),move |y| month_chart(y)); let month = RwSignal::new(Local::now().month());
let chart = Resource::new_blocking(move || (year.get(), theme.is_dark()), move |(y, dark)| month_chart(y, dark));
let detail_dlg = DialogOpener::new(); let detail_dlg = DialogOpener::new();
let reservations = create_blocking_resource(move || (year.get(), month.get(), detail_dlg.visible()), move |p| reservations_in_month(p.0, p.1)); let reservations = Resource::new_blocking(move || (year.get(), month.get(), detail_dlg.visible()), move |p| reservations_in_month(p.0, p.1));
let all_months: Vec<u32> = vec![1,2,3,4,5,6,7,8,9,10,11,12]; let all_months: Vec<u32> = vec![1,2,3,4,5,6,7,8,9,10,11,12];
let res_detail = create_rw_signal(ResSumWithItems::default()); let res_detail = RwSignal::new(ResSumWithItems::default());
view! { view! {
<h1>{trl("Booking overview")}</h1> <h1>{trl("Booking overview")}</h1>
<BookingDetail reservation=res_detail.read_only() opener=detail_dlg /> <BookingDetail reservation=res_detail.read_only() opener=detail_dlg />
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-5"> <nav class="navbar navbar-expand-lg navbar-light bg-light filter-bar mb-5">
<div class="container-fluid"> <div class="container-fluid">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"> <li class="nav-item">
@@ -94,7 +96,7 @@ pub fn bookings() -> impl IntoView {
</li> </li>
<li class="nav-item"> <li class="nav-item">
<Transition fallback=move || view! {<div>"Loading"</div>} > <Transition fallback=move || view! {<div>"Loading"</div>} >
{ {move || {
years.get().map(|y| match y { years.get().map(|y| match y {
Ok(y) => { Ok(y) => {
view! { view! {
@@ -106,11 +108,11 @@ pub fn bookings() -> impl IntoView {
<option prop:value=move || y prop:selected=move || year.get() == y>{y}</option> <option prop:value=move || y prop:selected=move || year.get() == y>{y}</option>
</For> </For>
</select> </select>
} }.into_any()
} }
Err(_) => {view! {<select><option></option></select>}} Err(_) => {view! {<select><option></option></select>}.into_any()}
}) })
} }}
</Transition> </Transition>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@@ -135,30 +137,24 @@ pub fn bookings() -> impl IntoView {
<div class="col-md"> <div class="col-md">
<div class="card md-3"> <div class="card md-3">
<Transition fallback=move || view! {<div>"Loading"</div>}> <Transition fallback=move || view! {<div>"Loading"</div>}>
{ {move || {
chart.get().map(move |c| match c { chart.get().map(move |c| match c {
Ok(c) => { match c { Ok(ApiResponse::Data(c)) => {view! {<img style="margin: 1em" src={c.clone()} /> }.into_any()}
ApiResponse::Data(c) => {view! {<img style="margin: 1em" src={c.clone()} /> }} _ => { view! {<img src=""/> }.into_any() }
ApiResponse::Error(_) => {view! {<img src=""/> }} }
}
Err(_) => { view! {<img src=""/> } }
}) })
} }}
</Transition> </Transition>
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<div class="card md-3"> <div class="card md-3">
<Transition fallback=move || view! {<div>"Loading"</div>}> <Transition fallback=move || view! {<div>"Loading"</div>}>
{ {move || {
year_chart.get().map(move |c| match c { year_chart.get().map(move |c| match c {
Ok(c) => { match c { Ok(ApiResponse::Data(c)) => {view! {<img style="margin: 1em" src={c.clone()} /> }.into_any()}
ApiResponse::Data(c) => {view! {<img style="margin: 1em" src={c.clone()} /> }} _ => { view! {<img src=""/> }.into_any() }
ApiResponse::Error(_) => {view! {<img src=""/> }} }
}
Err(_) => { view! {<img src=""/> } }
}) })
} }}
</Transition> </Transition>
</div> </div>
</div> </div>
@@ -178,7 +174,7 @@ pub fn bookings() -> impl IntoView {
<th>{trl("Actions")}</th> <th>{trl("Actions")}</th>
</tr> </tr>
</thead> </thead>
{ {move || {
reservations.get().map(|r| match r { reservations.get().map(|r| match r {
Ok(r) => { match r { Ok(r) => { match r {
ApiResponse::Data(r) => { ApiResponse::Data(r) => {
@@ -202,20 +198,20 @@ pub fn bookings() -> impl IntoView {
</tr> </tr>
</For> </For>
</tbody> </tbody>
} }.into_any()
} }
ApiResponse::Error(e) => { ApiResponse::Error(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>} <tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()
} }
} }
} }
Err(e) => { Err(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>} <tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()
} }
}) })
} }}
</table> </table>
</Transition> </Transition>
</div> </div>
+16 -15
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance}; use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance};
use crate::backend::data::Appearance; use crate::backend::data::Appearance;
use crate::components::data_form::{DataForm, QuestionDialog}; use crate::components::data_form::{DataForm, QuestionDialog};
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView { fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
let update = create_server_action::<UpdateAppearance>(); let update = ServerAction::<UpdateAppearance>::new();
view! { view! {
<DataForm opener=opener action=update title="Update title"> <DataForm opener=opener action=update title="Update title">
<input type="hidden" prop:value={move || appearance.get().text.unwrap_or_default()} name="appearance[text]" /> <input type="hidden" prop:value={move || appearance.get().text.unwrap_or_default()} name="appearance[text]" />
@@ -31,7 +31,7 @@ fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl
#[component] #[component]
fn edit_text(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView { fn edit_text(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
let update = create_server_action::<UpdateAppearance>(); let update = ServerAction::<UpdateAppearance>::new();
view! { view! {
<DataForm opener=opener action=update title="Update text"> <DataForm opener=opener action=update title="Update text">
<input type="hidden" prop:value={move || appearance.get().title.unwrap_or_default()} name="appearance[title]" /> <input type="hidden" prop:value={move || appearance.get().title.unwrap_or_default()} name="appearance[title]" />
@@ -53,7 +53,7 @@ fn edit_text(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl I
#[component] #[component]
fn delete_banner(opener: DialogOpener) -> impl IntoView { fn delete_banner(opener: DialogOpener) -> impl IntoView {
let delete = create_server_action::<DeleteBanner>(); let delete = ServerAction::<DeleteBanner>::new();
view! { view! {
<QuestionDialog opener=opener action=delete title="Delete banner"> <QuestionDialog opener=opener action=delete title="Delete banner">
<div>{trl("Are you sure you want to delete banner?")}</div> <div>{trl("Are you sure you want to delete banner?")}</div>
@@ -66,8 +66,8 @@ pub fn appearance() -> impl IntoView {
let title_edit = DialogOpener::new(); let title_edit = DialogOpener::new();
let text_edit = DialogOpener::new(); let text_edit = DialogOpener::new();
let del_dialog = DialogOpener::new(); let del_dialog = DialogOpener::new();
let appearance = create_resource(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance()); let appearance = Resource::new(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
let app_edit = create_rw_signal(Appearance::default()); let app_edit = RwSignal::new(Appearance::default());
view! { view! {
<EditTitle opener=title_edit appearance=app_edit.read_only()/> <EditTitle opener=title_edit appearance=app_edit.read_only()/>
<EditText opener=text_edit appearance=app_edit.read_only()/> <EditText opener=text_edit appearance=app_edit.read_only()/>
@@ -85,16 +85,17 @@ pub fn appearance() -> impl IntoView {
</button> </button>
</form><br/> </form><br/>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{ {move || {
appearance.get().map(|a| match a { appearance.get().map(|a| match a {
Ok(a) => { Ok(app) => {
app_edit.set(a.clone()); app_edit.set(app.clone());
let app = a.clone(); let has_banner = app.banner.is_some();
let title = app.title.clone().unwrap_or_default();
view! { view! {
<div> <div>
<Show when=move || a.clone().banner.is_some()> <Show when=move || has_banner>
<div class="header_banner"> <div class="header_banner">
<h1 class="header_banner">{app.title.clone().unwrap_or_default()}</h1> <h1 class="header_banner">{title.clone()}</h1>
</div> </div>
</Show> </Show>
<div> <div>
@@ -111,13 +112,13 @@ pub fn appearance() -> impl IntoView {
<i class="bx bx-edit-alt me-1"></i> {trl("Edit text")}</a> <i class="bx bx-edit-alt me-1"></i> {trl("Edit text")}</a>
</div> </div>
</div> </div>
} }.into_any()
} }
Err(e) => {view! {<div><p>{trl("Error loading data")}</p> Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div> <p>{e.to_string()}</p></div>
}} }.into_any()}
}) })
} }}
</Transition> </Transition>
</div> </div>
+3 -3
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::User; use crate::backend::data::User;
use crate::backend::user::{ChangePwd, get_user}; use crate::backend::user::{ChangePwd, get_user};
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
@@ -7,8 +7,8 @@ use crate::locales::trl;
#[component] #[component]
pub fn change_password(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView { pub fn change_password(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView {
let change_pwd = create_server_action::<ChangePwd>(); let change_pwd = ServerAction::<ChangePwd>::new();
let logged_in = create_resource(||(), move |_| get_user()); let logged_in = Resource::new(||(), move |_| get_user());
view! { view! {
<DataForm opener=opener action=change_pwd title="Change password"> <DataForm opener=opener action=change_pwd title="Change password">
+7 -7
View File
@@ -1,5 +1,5 @@
use chrono::Local; use chrono::Local;
use leptos::*; use leptos::prelude::*;
use crate::backend::data::ClosingTime; use crate::backend::data::ClosingTime;
use crate::backend::opening_hours::{get_closing_times, DeleteClosingTime, InsertClosingTime}; use crate::backend::opening_hours::{get_closing_times, DeleteClosingTime, InsertClosingTime};
use crate::components::data_form::{DataForm, QuestionDialog}; use crate::components::data_form::{DataForm, QuestionDialog};
@@ -8,7 +8,7 @@ use crate::locales::{loc_date, trl};
#[component] #[component]
pub fn del_closing_day(closing_time: ReadSignal<ClosingTime>, opener: DialogOpener) -> impl IntoView { pub fn del_closing_day(closing_time: ReadSignal<ClosingTime>, opener: DialogOpener) -> impl IntoView {
let delete = create_server_action::<DeleteClosingTime>(); let delete = ServerAction::<DeleteClosingTime>::new();
view! { view! {
<QuestionDialog opener=opener action=delete title="Delete closing days"> <QuestionDialog opener=opener action=delete title="Delete closing days">
@@ -20,7 +20,7 @@ pub fn del_closing_day(closing_time: ReadSignal<ClosingTime>, opener: DialogOpen
#[component] #[component]
pub fn insert_closing_days(opener: DialogOpener) -> impl IntoView { pub fn insert_closing_days(opener: DialogOpener) -> impl IntoView {
let insert_day = create_server_action::<InsertClosingTime>(); let insert_day = ServerAction::<InsertClosingTime>::new();
view! { view! {
<DataForm opener=opener action=insert_day title="Closing days"> <DataForm opener=opener action=insert_day title="Closing days">
@@ -52,8 +52,8 @@ pub fn insert_closing_days(opener: DialogOpener) -> impl IntoView {
pub fn closing_days() -> impl IntoView { pub fn closing_days() -> impl IntoView {
let delete_dialog = DialogOpener::new(); let delete_dialog = DialogOpener::new();
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let times = create_blocking_resource(move || editor.visible() || delete_dialog.visible(), move |_| {get_closing_times()}); let times = Resource::new_blocking(move || editor.visible() || delete_dialog.visible(), move |_| {get_closing_times()});
let time_to_del = create_rw_signal(ClosingTime::default()); let time_to_del = RwSignal::new(ClosingTime::default());
view! { view! {
<DelClosingDay closing_time=time_to_del.read_only() opener=delete_dialog/> <DelClosingDay closing_time=time_to_del.read_only() opener=delete_dialog/>
<InsertClosingDays opener=editor/> <InsertClosingDays opener=editor/>
@@ -78,7 +78,7 @@ pub fn closing_days() -> impl IntoView {
e.to_string() e.to_string()
}; };
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=3>{trl(&err)}</td></tr></tbody>}} <tr><td colspan=3>{trl(&err)}</td></tr></tbody>}.into_any()}
Ok(c) => { Ok(c) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<For each=move || c.clone() <For each=move || c.clone()
@@ -100,7 +100,7 @@ pub fn closing_days() -> impl IntoView {
</tr> </tr>
} }
}/></tbody> }/></tbody>
} }.into_any()
} }
}) })
}} }}
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::backend::data::Company; use crate::backend::data::Company;
use crate::backend::company::UpdateCompany; use crate::backend::company::UpdateCompany;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use leptos::*; use leptos::prelude::*;
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
use crate::locales::trl; use crate::locales::trl;
@@ -10,7 +10,7 @@ pub fn CompanyEdit(
company: ReadSignal<Company>, company: ReadSignal<Company>,
opener: DialogOpener, opener: DialogOpener,
) -> impl IntoView { ) -> impl IntoView {
let update_company = create_server_action::<UpdateCompany>(); let update_company = ServerAction::<UpdateCompany>::new();
view! { view! {
<DataForm opener=opener action=update_company title="Edit company"> <DataForm opener=opener action=update_company title="Edit company">
+8 -8
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::company::get_company; use crate::backend::company::get_company;
use crate::backend::data::{ApiResponse, Company}; use crate::backend::data::{ApiResponse, Company};
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -8,21 +8,21 @@ use crate::pages::company_edit::CompanyEdit;
#[component] #[component]
pub fn CompanyInfo() -> impl IntoView { pub fn CompanyInfo() -> impl IntoView {
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let company = create_blocking_resource(move|| editor.visible(), move |_| { get_company() }); let company = Resource::new_blocking(move|| editor.visible(), move |_| { get_company() });
let (cmp, set_cmp) = create_signal(Company::default()); let (cmp, set_cmp) = signal(Company::default());
view! { view! {
<CompanyEdit company={cmp} opener=editor/> <CompanyEdit company={cmp} opener=editor/>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"><i class="bx bx-buildings"></i>" "{trl("Company info")}</h5> <h5 class="card-title"><i class="bx bx-buildings"></i>" "{trl("Company info")}</h5>
<p class="card-text"> <div class="card-text">
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || { {move || {
company.get().map(|c| match c { company.get().map(|c| match c {
Err(e) => {view! {<div><p>{trl("Error loading data")}</p> Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div> <p>{e.to_string()}</p></div>
}} }.into_any()}
Ok(c) => { Ok(c) => {
match c { match c {
ApiResponse::Data(d) => {set_cmp.update(|cmp| *cmp = d.clone()); ApiResponse::Data(d) => {set_cmp.update(|cmp| *cmp = d.clone());
@@ -30,16 +30,16 @@ pub fn CompanyInfo() -> impl IntoView {
<p><b>{d.name}</b></p> <p><b>{d.name}</b></p>
<p>{d.street}" "{d.house_number}<br/> <p>{d.street}" "{d.house_number}<br/>
{d.zip_code}" "{d.city} {d.zip_code}" "{d.city}
</p></div>} </p></div>}.into_any()
} }
ApiResponse::Error(_) => {view! {<div><p>{trl("Error loading data")}</p></div>}} ApiResponse::Error(_) => {view! {<div><p>{trl("Error loading data")}</p></div>}.into_any()}
} }
} }
}) })
}} }}
</Transition> </Transition>
</p> </div>
<a href="javascript:void(0)" class="card-link" on:click = move |_| editor.show()> <a href="javascript:void(0)" class="card-link" on:click = move |_| editor.show()>
<i class="bx bx-edit-alt fs-4 lh-0"></i></a> <i class="bx bx-edit-alt fs-4 lh-0"></i></a>
</div> </div>
+7 -7
View File
@@ -1,11 +1,11 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::customer::get_customers; use crate::backend::customer::get_customers;
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::locales::trl; use crate::locales::trl;
#[component] #[component]
pub fn customers() -> impl IntoView { pub fn customers() -> impl IntoView {
let customers = create_blocking_resource(||(), |_| get_customers()); let customers = Resource::new_blocking(||(), |_| get_customers());
view! { view! {
<h1>{trl("Customers")}</h1> <h1>{trl("Customers")}</h1>
@@ -21,7 +21,7 @@ pub fn customers() -> impl IntoView {
<th>{trl("Phone")}</th> <th>{trl("Phone")}</th>
</tr> </tr>
</thead> </thead>
{ {move || {
customers.get().map(|c| match c { customers.get().map(|c| match c {
Ok(c) => {match c { Ok(c) => {match c {
ApiResponse::Data(c) => { ApiResponse::Data(c) => {
@@ -35,20 +35,20 @@ pub fn customers() -> impl IntoView {
</tr> </tr>
</For> </For>
</tbody> </tbody>
} }.into_any()
} }
ApiResponse::Error(e) => { ApiResponse::Error(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=3>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>} <tr><td colspan=3>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()
} }
} }
} }
Err(e) => { Err(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=3>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>} <tr><td colspan=3>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()
} }
}) })
} }}
</table> </table>
</Transition> </Transition>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use crate::locales::trl; use crate::locales::trl;
use crate::pages::new_reservations::NewReservations; use crate::pages::new_reservations::NewReservations;
+3 -3
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::WeekHours; use crate::backend::data::WeekHours;
use crate::backend::opening_hours::{DeleteClosingTime, UpdateHours}; use crate::backend::opening_hours::{DeleteClosingTime, UpdateHours};
use crate::components::data_form::{DataForm, QuestionDialog}; use crate::components::data_form::{DataForm, QuestionDialog};
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
pub fn EditHours(opener: DialogOpener, hours: ReadSignal<WeekHours>) -> impl IntoView { pub fn EditHours(opener: DialogOpener, hours: ReadSignal<WeekHours>) -> impl IntoView {
let update_hours = create_server_action::<UpdateHours>(); let update_hours = ServerAction::<UpdateHours>::new();
view! { view! {
<DataForm opener=opener action=update_hours title="Edit hours"> <DataForm opener=opener action=update_hours title="Edit hours">
@@ -31,7 +31,7 @@ pub fn EditHours(opener: DialogOpener, hours: ReadSignal<WeekHours>) -> impl Int
#[component] #[component]
pub fn del_closing_days(opener: DialogOpener) -> impl IntoView { pub fn del_closing_days(opener: DialogOpener) -> impl IntoView {
let delete = create_server_action::<DeleteClosingTime>(); let delete = ServerAction::<DeleteClosingTime>::new();
view! { view! {
<QuestionDialog opener=opener action=delete title="Delete closing days"> <QuestionDialog opener=opener action=delete title="Delete closing days">
+9 -6
View File
@@ -1,6 +1,6 @@
use leptos::*; use leptos::prelude::*;
use leptos_meta::*; use leptos_meta::*;
use leptos_router::ActionForm; use leptos::form::ActionForm;
use crate::backend::user::Login; use crate::backend::user::Login;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use crate::components::server_err::ServerErr; use crate::components::server_err::ServerErr;
@@ -8,8 +8,9 @@ use crate::locales::trl;
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
let login = create_server_action::<Login>(); let login = ServerAction::<Login>::new();
let login_val = login.value(); let login_val = Signal::derive(move || login.value().get());
let show_password = RwSignal::new(false);
view! { view! {
<Link rel="stylesheet" href="/vendor/css/pages/page-auth.css" /> <Link rel="stylesheet" href="/vendor/css/pages/page-auth.css" />
<div class="authentication-wrapper authentication-basic container-p-y"> <div class="authentication-wrapper authentication-basic container-p-y">
@@ -50,13 +51,15 @@ pub fn Login() -> impl IntoView {
</div> </div>
<div class="input-group input-group-merge"> <div class="input-group input-group-merge">
<input <input
type="password" type=move || if show_password.get() {"text"} else {"password"}
id="password" id="password"
class="form-control" class="form-control"
name="password" name="password"
aria-describedby="password" aria-describedby="password"
/> />
<span class="input-group-text cursor-pointer"><i class="bx bx-hide"></i></span> <span class="input-group-text cursor-pointer" on:click=move |_| show_password.update(|show| *show = !*show)>
<i class=move || if show_password.get() {"bx bx-show"} else {"bx bx-hide"}></i>
</span>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
+1 -1
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::MessageType; use crate::backend::data::MessageType;
use crate::locales::trl; use crate::locales::trl;
use crate::pages::mail_view::MailView; use crate::pages::mail_view::MailView;
+10 -10
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{Message, MessageType}; use crate::backend::data::{Message, MessageType};
use crate::backend::mail::{get_message, UpdateMessage}; use crate::backend::mail::{get_message, UpdateMessage};
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
fn mail_edit(opener: DialogOpener, mail: ReadSignal<Message>) -> impl IntoView { fn mail_edit(opener: DialogOpener, mail: ReadSignal<Message>) -> impl IntoView {
let update = create_server_action::<UpdateMessage>(); let update = ServerAction::<UpdateMessage>::new();
view! { view! {
<DataForm opener=opener title="Edit mail" action=update> <DataForm opener=opener title="Edit mail" action=update>
@@ -44,17 +44,17 @@ fn mail_edit(opener: DialogOpener, mail: ReadSignal<Message>) -> impl IntoView {
#[component] #[component]
pub fn mail_view(title: &'static str, mail_type: MessageType) -> impl IntoView { pub fn mail_view(title: &'static str, mail_type: MessageType) -> impl IntoView {
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let mail = create_blocking_resource( move || editor.visible(), move |_| get_message(mail_type.clone())); let mail = Resource::new_blocking( move || editor.visible(), move |_| get_message(mail_type.clone()));
let for_edit = create_rw_signal(Message::default()); let for_edit = RwSignal::new(Message::default());
view! { view! {
<MailEdit opener=editor mail={for_edit.read_only()}/> <MailEdit opener=editor mail={for_edit.read_only()}/>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"><i class="bx bx-buildings"></i>" "{trl(title)}</h5> <h5 class="card-title"><i class="bx bx-buildings"></i>" "{trl(title)}</h5>
<p class="card-text"> <div class="card-text">
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{ {move || {
mail.get().map(|m| match m { mail.get().map(|m| match m {
Ok(m) => { Ok(m) => {
for_edit.set(m.clone()); for_edit.set(m.clone());
@@ -63,16 +63,16 @@ pub fn mail_view(title: &'static str, mail_type: MessageType) -> impl IntoView {
<p>{trl("Subject: ")}{m.subject}</p> <p>{trl("Subject: ")}{m.subject}</p>
<p>{m.text}</p> <p>{m.text}</p>
</div> </div>
} }.into_any()
} }
Err(_) => { Err(_) => {
view! { view! {
<div>"Error loading data"</div> <div>"Error loading data"</div>
} }.into_any()
}}) }})
} }}
</Transition> </Transition>
</p> </div>
<a href="javascript:void(0)" class="card-link" on:click = move |_| editor.show()> <a href="javascript:void(0)" class="card-link" on:click = move |_| editor.show()>
<i class="bx bx-edit-alt fs-4 lh-0"></i></a> <i class="bx bx-edit-alt fs-4 lh-0"></i></a>
</div> </div>
+15 -15
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ApiResponse, Page, ResSumWithItems}; use crate::backend::data::{ApiResponse, Page, ResSumWithItems};
use crate::backend::reservation::{Approve, Cancel, get_new_reservations}; use crate::backend::reservation::{Approve, Cancel, get_new_reservations};
use crate::components::data_form::QuestionDialog; use crate::components::data_form::QuestionDialog;
@@ -10,7 +10,7 @@ use chrono::Datelike;
#[component] #[component]
fn approve_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView { fn approve_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
let approve = create_server_action::<Approve>(); let approve = ServerAction::<Approve>::new();
view! { view! {
<QuestionDialog opener=opener action=approve title="Approve booking"> <QuestionDialog opener=opener action=approve title="Approve booking">
@@ -27,7 +27,7 @@ fn approve_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
#[component] #[component]
pub fn cancel_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView { pub fn cancel_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
let cancel = create_server_action::<Cancel>(); let cancel = ServerAction::<Cancel>::new();
view! { view! {
<QuestionDialog opener=opener action=cancel title="Cancel booking"> <QuestionDialog opener=opener action=cancel title="Cancel booking">
@@ -56,13 +56,13 @@ fn create_dialog(opener: DialogOpener) -> impl IntoView {
#[component] #[component]
pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView { pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView {
let create = DialogOpener::new(); let create = DialogOpener::new();
let page = create_rw_signal(Page::new(5)); let page = RwSignal::new(Page::new(5));
let first = create_rw_signal(false); let first = RwSignal::new(false);
let last = create_rw_signal(false); let last = RwSignal::new(false);
let total_pages = create_rw_signal(0i32); let total_pages = RwSignal::new(0i32);
let res = create_blocking_resource(move || (app_opener.visible() || cancel_opener.visible() || create.visible(), page), let res = Resource::new_blocking(move || (app_opener.visible() || cancel_opener.visible() || create.visible(), page),
move |d| get_new_reservations(d.1.get())); move |d| get_new_reservations(d.1.get()));
let reservation = create_rw_signal(ResSumWithItems::default()); let reservation = RwSignal::new(ResSumWithItems::default());
view! { view! {
<ApproveDialog opener=app_opener reservation=reservation.read_only() /> <ApproveDialog opener=app_opener reservation=reservation.read_only() />
@@ -75,7 +75,7 @@ pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -
{move || { {move || {
res.get().map(|r| match r { res.get().map(|r| match r {
Err(e) => { Err(e) => {
view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}} view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}.into_any()}
Ok(r) => { match r { Ok(r) => { match r {
ApiResponse::Data(r) => { ApiResponse::Data(r) => {
first.set(r.is_first()); first.set(r.is_first());
@@ -98,7 +98,7 @@ pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -
let:item> let:item>
{item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/> {item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/>
</For> </For>
{trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email}</a>", "{data.customer.phone}<br/> {trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email.clone()}</a>", "{data.customer.phone}<br/>
{move || { {move || {
let note = data.summary.note.clone(); let note = data.summary.note.clone();
let show = note.is_some() && !note.clone().unwrap().is_empty(); let show = note.is_some() && !note.clone().unwrap().is_empty();
@@ -109,7 +109,7 @@ pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -
} }
}} }}
{trl("Price: ")}{data.summary.price.to_string()}<br/> {trl("Price: ")}{data.summary.price.to_string()}<br/>
<table width="100%"> <table style="width: 100%"><tbody>
<tr> <tr>
<td width="100%"></td> <td width="100%"></td>
<td> <td>
@@ -135,15 +135,15 @@ pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -
</div> </div>
</td> </td>
</tr> </tr>
</table> </tbody></table>
<hr/> <hr/>
} }
}} }}
</For> </For>
</div> </div>
} }.into_any()
} }
ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}}} ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}.into_any()}}
} }
}) })
}} }}
+11 -11
View File
@@ -1,5 +1,5 @@
use chrono::Weekday; use chrono::Weekday;
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{DayHours, WeekHours}; use crate::backend::data::{DayHours, WeekHours};
use crate::backend::opening_hours::get_hours; use crate::backend::opening_hours::get_hours;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -18,8 +18,8 @@ fn show_time(tm: &str) -> impl Fn() -> String {
pub fn OpeningHours() -> impl IntoView { pub fn OpeningHours() -> impl IntoView {
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let closing_delete = DialogOpener::new(); let closing_delete = DialogOpener::new();
let hours = create_blocking_resource(move || editor.visible(), move |_| {get_hours()}); let hours = Resource::new_blocking(move || editor.visible(), move |_| {get_hours()});
let hrs = create_rw_signal(WeekHours::default()); let hrs = RwSignal::new(WeekHours::default());
view! { view! {
<EditHours opener=editor hours=hrs.read_only() /> <EditHours opener=editor hours=hrs.read_only() />
@@ -27,16 +27,16 @@ pub fn OpeningHours() -> impl IntoView {
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"><i class="bx bxs-watch"></i>" "{trl("Opening hours")}</h5> <h5 class="card-title"><i class="bx bxs-watch"></i>" "{trl("Opening hours")}</h5>
<p class="card-text"> <div class="card-text">
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || { {move || {
hours.get().map(|h| match h { hours.get().map(|h| match h {
Ok(h) => { Ok(h) => {
let h = create_rw_signal(h); let h = RwSignal::new(h);
let d = create_rw_signal((0..7).collect::<Vec<u8>>()); let d = RwSignal::new((0..7).collect::<Vec<u8>>());
view! { view! {
<div> <div>
<table class="table card-table"> <table class="table card-table"><tbody>
<For each=move || d.get() key=|day| *day <For each=move || d.get() key=|day| *day
children=move |day| { children=move |day| {
let week_day = Weekday::try_from(day).unwrap_or(Weekday::Mon); let week_day = Weekday::try_from(day).unwrap_or(Weekday::Mon);
@@ -54,17 +54,17 @@ pub fn OpeningHours() -> impl IntoView {
} }
} }
/> />
</table> </tbody></table>
</div> </div>
} }.into_any()
} }
Err(e) => {view! {<div><p>{trl("Error loading data")}</p> Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div> <p>{e.to_string()}</p></div>
}} }.into_any()}
}) })
}} }}
</Transition> </Transition>
</p> </div>
</div> </div>
</div> </div>
} }
+3 -3
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::User; use crate::backend::data::User;
use crate::backend::user::{get_user, UpdateProfile}; use crate::backend::user::{get_user, UpdateProfile};
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
@@ -7,8 +7,8 @@ use crate::locales::trl;
#[component] #[component]
pub fn ProfileEdit(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView { pub fn ProfileEdit(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView {
let update_user = create_server_action::<UpdateProfile>(); let update_user = ServerAction::<UpdateProfile>::new();
let logged_in = create_resource(||(), move |_| get_user()); let logged_in = Resource::new(||(), move |_| get_user());
view! { view! {
<DataForm opener=opener action=update_user title="Edit profile"> <DataForm opener=opener action=update_user title="Edit profile">
+12 -12
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use leptos_use::use_media_query; use leptos_use::use_media_query;
use crate::backend::data::{ApiResponse, ResProperty}; use crate::backend::data::{ApiResponse, ResProperty};
use crate::backend::property::get_properties; use crate::backend::property::get_properties;
@@ -11,13 +11,13 @@ use crate::pages::property_edit::PropertyEdit;
#[component] #[component]
pub fn properties() -> impl IntoView { pub fn properties() -> impl IntoView {
let is_wide = use_media_query("(min-width: 500px)"); let is_wide = use_media_query("(min-width: 500px)");
let properties = create_rw_signal::<Vec<ResProperty>>(vec![]); let properties = RwSignal::<Vec<ResProperty>>::new(vec![]);
let prop = create_rw_signal(ResProperty::default()); let prop = RwSignal::new(ResProperty::default());
let empty_prop = create_rw_signal(ResProperty::default()); let empty_prop = RwSignal::new(ResProperty::default());
let create_form = DialogOpener::new(); let create_form = DialogOpener::new();
let edit_form = DialogOpener::new(); let edit_form = DialogOpener::new();
let delete_dlg = DialogOpener::new(); let delete_dlg = DialogOpener::new();
let props = create_blocking_resource(move || create_form.visible() || edit_form.visible() || delete_dlg.visible(), move |_| get_properties()); let props = Resource::new_blocking(move || create_form.visible() || edit_form.visible() || delete_dlg.visible(), move |_| get_properties());
view! { view! {
<PropertyEdit property=prop.read_only() opener=edit_form edit=true/> <PropertyEdit property=prop.read_only() opener=edit_form edit=true/>
@@ -31,8 +31,8 @@ pub fn properties() -> impl IntoView {
<thead> <thead>
<tr> <tr>
<th>{trl("Name")}</th> <th>{trl("Name")}</th>
{move || if is_wide.get() {view! {<th>{trl("Description")}</th>}} {move || if is_wide.get() {view! {<th>{trl("Description")}</th>}.into_any()}
else {view! {<th></th>}} } else {view! {<th></th>}.into_any()} }
<th>{trl("Price")}</th> <th>{trl("Price")}</th>
<th>{trl("Actions")}</th> <th>{trl("Actions")}</th>
</tr> </tr>
@@ -41,7 +41,7 @@ pub fn properties() -> impl IntoView {
props.get().map(|u| match u { props.get().map(|u| match u {
Err(e) => { Err(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=4>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}} <tr><td colspan=4>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()}
Ok(u) => { Ok(u) => {
match u { match u {
ApiResponse::Data(p) => { ApiResponse::Data(p) => {
@@ -58,8 +58,8 @@ pub fn properties() -> impl IntoView {
view! { view! {
<tr> <tr>
<td>{data.name.clone()}</td> <td>{data.name.clone()}</td>
{move || if is_wide.get() {view! {<td>{data.description.clone()}</td>}} {move || if is_wide.get() {view! {<td>{data.description.clone()}</td>}.into_any()}
else {view! {<td></td>}} } else {view! {<td></td>}.into_any()} }
<td>{data.price.to_string()}</td> <td>{data.price.to_string()}</td>
<td> <td>
<div class="dropdown"> <div class="dropdown">
@@ -87,11 +87,11 @@ pub fn properties() -> impl IntoView {
}} }}
</For> </For>
</tbody> </tbody>
} }.into_any()
}, },
ApiResponse::Error(s) => { ApiResponse::Error(s) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=4>{trl(&s)}</td></tr></tbody>} <tr><td colspan=4>{trl(&s)}</td></tr></tbody>}.into_any()
} }
} }
} }
+2 -2
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::ResProperty; use crate::backend::data::ResProperty;
use crate::backend::property::DeleteProperty; use crate::backend::property::DeleteProperty;
use crate::components::data_form::QuestionDialog; use crate::components::data_form::QuestionDialog;
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
pub fn property_delete(property: ReadSignal<ResProperty>, opener: DialogOpener) -> impl IntoView { pub fn property_delete(property: ReadSignal<ResProperty>, opener: DialogOpener) -> impl IntoView {
let del_property = create_server_action::<DeleteProperty>(); let del_property = ServerAction::<DeleteProperty>::new();
view! { view! {
<QuestionDialog opener=opener action=del_property title="Delete property"> <QuestionDialog opener=opener action=del_property title="Delete property">
+8 -8
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ResProperty, SlotType}; use crate::backend::data::{ResProperty, SlotType};
use crate::backend::property::{CreateProperty, EditProperty}; use crate::backend::property::{CreateProperty, EditProperty};
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
fn form_inner(property: ReadSignal<ResProperty>) -> impl IntoView { fn form_inner(property: ReadSignal<ResProperty>) -> impl IntoView {
let active_str = create_rw_signal("false".to_string()); let active_str = RwSignal::new("false".to_string());
view! { view! {
<input type="hidden" prop:value={move || property.get().id()} name="property[id]"/> <input type="hidden" prop:value={move || property.get().id()} name="property[id]"/>
<div class="row"> <div class="row">
@@ -79,9 +79,9 @@ fn form_inner(property: ReadSignal<ResProperty>) -> impl IntoView {
<input type="hidden" prop:value=active_str name="property[active]"/> <input type="hidden" prop:value=active_str name="property[active]"/>
</div> </div>
</div> </div>
} }.into_any()
} else { } else {
view! {<div></div>} view! {<div></div>}.into_any()
} }
} }
}} }}
@@ -89,19 +89,19 @@ fn form_inner(property: ReadSignal<ResProperty>) -> impl IntoView {
#[component] #[component]
pub fn property_edit(property: ReadSignal<ResProperty>, edit: bool, opener: DialogOpener) -> impl IntoView { pub fn property_edit(property: ReadSignal<ResProperty>, edit: bool, opener: DialogOpener) -> impl IntoView {
let action_create = create_server_action::<CreateProperty>(); let action_create = ServerAction::<CreateProperty>::new();
let action_edit = create_server_action::<EditProperty>(); let action_edit = ServerAction::<EditProperty>::new();
view! { view! {
{move || {move ||
if edit { if edit {
view! {<DataForm opener=opener action=action_edit title="Edit property"> view! {<DataForm opener=opener action=action_edit title="Edit property">
<FormInner property=property/> <FormInner property=property/>
</DataForm>}} </DataForm>}.into_any()}
else { else {
view! {<DataForm opener=opener action=action_create title="Create property"> view! {<DataForm opener=opener action=action_create title="Create property">
<FormInner property=property/> <FormInner property=property/>
</DataForm>}} </DataForm>}.into_any()}
} }
} }
} }
+31 -29
View File
@@ -1,7 +1,8 @@
use chrono::{Duration, Local, NaiveDate, NaiveTime, Timelike}; use chrono::{Duration, Local, NaiveDate, NaiveTime, Timelike};
use leptos::*; use leptos::prelude::*;
use leptos_captcha::{Captcha, pow_dispatch}; use leptos_captcha::{Captcha, pow_dispatch};
use leptos_router::*; use leptos::form::{ActionForm, FromFormData};
use leptos::ev::SubmitEvent;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use crate::backend::appearance::get_appearance; use crate::backend::appearance::get_appearance;
use crate::backend::customer::get_remembered; use crate::backend::customer::get_remembered;
@@ -36,7 +37,7 @@ fn time_selector(
match property.slot { match property.slot {
SlotType::Quarter => { SlotType::Quarter => {
let mut ret: Vec<TmCheck> = vec![]; let mut ret: Vec<TmCheck> = vec![];
logging::log!("quarter"); leptos::logging::log!("quarter");
for n in 0..(h.to() - h.from()).num_minutes() * 4 / 60 { for n in 0..(h.to() - h.from()).num_minutes() * 4 / 60 {
ret.push(TmCheck { ret.push(TmCheck {
from: h.from() + Duration::minutes(n * 15), from: h.from() + Duration::minutes(n * 15),
@@ -47,7 +48,7 @@ fn time_selector(
} }
SlotType::Half => { SlotType::Half => {
let mut ret: Vec<TmCheck> = vec![]; let mut ret: Vec<TmCheck> = vec![];
logging::log!("half"); leptos::logging::log!("half");
for n in 0..(h.to() - h.from()).num_minutes() * 2 / 60 { for n in 0..(h.to() - h.from()).num_minutes() * 2 / 60 {
ret.push(TmCheck { ret.push(TmCheck {
from: h.from() + Duration::minutes(n * 30), from: h.from() + Duration::minutes(n * 30),
@@ -110,22 +111,22 @@ fn time_selector(
#[component] #[component]
pub fn Public() -> impl IntoView { pub fn Public() -> impl IntoView {
let day = create_rw_signal(NaiveDate::default()); let day = RwSignal::new(NaiveDate::default());
let form_data = create_blocking_resource(move || day.get(), move |d| get_public_form_data(d)); let form_data = Resource::new_blocking(move || day.get(), move |d| get_public_form_data(d));
let slots = create_rw_signal::<Vec<String>>(vec![]); let slots = RwSignal::<Vec<String>>::new(vec![]);
let price = create_rw_signal(Decimal::from(0)); let price = RwSignal::new(Decimal::from(0));
let cr_reservation = create_server_action::<CreateReservation>(); let cr_reservation = ServerAction::<CreateReservation>::new();
let validator = Validator::new(); let validator = Validator::new();
let invalid_dlg = DialogOpener::new(); let invalid_dlg = DialogOpener::new();
let result_dlg = DialogOpener::new(); let result_dlg = DialogOpener::new();
let result = cr_reservation.value(); let result = Signal::derive(move || cr_reservation.value().get());
let is_pending = create_rw_signal(None); let is_pending = RwSignal::new(None);
let active_str = create_rw_signal("true".to_string()); let active_str = RwSignal::new("true".to_string());
let get_customer = create_blocking_resource(||(), move |_| get_remembered()); let get_customer = Resource::new_blocking(||(), move |_| get_remembered());
let appearance = create_blocking_resource(||(), move |_| get_appearance()); let appearance = Resource::new_blocking(||(), move |_| get_appearance());
let customer = create_rw_signal(Customer::default()); let customer = RwSignal::new(Customer::default());
create_effect(move |_| { Effect::new(move |_| {
day.set(Local::now().date_naive()); day.set(Local::now().date_naive());
}); });
@@ -139,36 +140,37 @@ pub fn Public() -> impl IntoView {
captcha_state=is_pending.read_only()/> captcha_state=is_pending.read_only()/>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{ {move || {
appearance.get().map(|a| match a { appearance.get().map(|a| match a {
Ok(a) => { Ok(a) => {
let app = a.clone(); let has_banner = a.banner.is_some();
let title = a.title.clone().unwrap_or_default();
view! { view! {
<div> <div>
<Show when=move || a.clone().banner.is_some()> <Show when=move || has_banner>
<div class="header_banner"> <div class="header_banner">
<h1 class="header_banner">{app.title.clone().unwrap_or_default()}</h1> <h1 class="header_banner">{title.clone()}</h1>
</div> </div>
</Show> </Show>
<div> <div>
<div class="header_banner_text" inner_html={app.text.unwrap_or("".to_string())}> <div class="header_banner_text" inner_html={a.text.unwrap_or("".to_string())}>
</div> </div>
</div> </div>
</div> </div>
} }.into_any()
}, },
Err(e) => {view! {<div><p>{trl("Error loading data")}</p> Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div> <p>{e.to_string()}</p></div>
}} }.into_any()}
}) })
} }}
</Transition> </Transition>
<div class="card-body"> <div class="card-body">
<ActionForm <ActionForm
on:submit=move |ev| { // Capture phase, so the form is validated and the captcha solved before it is dispatched
cr_reservation.set_pending(true); on:submit:capture=move |ev: SubmitEvent| {
if let Ok(mut act) = CreateReservation::from_event(&ev) { if let Ok(mut act) = CreateReservation::from_event(&ev) {
validator.check(act.entity(), &ev); validator.check(act.entity(), &ev);
@@ -219,7 +221,7 @@ pub fn Public() -> impl IntoView {
}); });
form_data.get().map(|u| match u { form_data.get().map(|u| match u {
Err(e) => { Err(e) => {
view! {<div>{e.to_string()}</div>}} view! {<div>{e.to_string()}</div>}.into_any()}
Ok(u) => { Ok(u) => {
match u { match u {
ApiResponse::Data(p) => { ApiResponse::Data(p) => {
@@ -252,10 +254,10 @@ pub fn Public() -> impl IntoView {
value={data}/> value={data}/>
</For> </For>
</div> </div>
} }.into_any()
}, },
ApiResponse::Error(s) => { ApiResponse::Error(s) => {
view! {<div>{trl(&s)}</div>} view! {<div>{trl(&s)}</div>}.into_any()
} }
}} }}
}) })
+7 -6
View File
@@ -1,5 +1,5 @@
use chrono::{Local, NaiveDate}; use chrono::{Local, NaiveDate};
use leptos::*; use leptos::prelude::*;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter}; use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter};
@@ -29,7 +29,8 @@ pub fn res_error(opener: DialogOpener, validator: Validator) -> impl IntoView {
#[component] #[component]
pub fn res_saved( pub fn res_saved(
opener: DialogOpener, opener: DialogOpener,
save_result: RwSignal<Option<Result<ApiResponse<NaiveDate>, ServerFnError>>>, #[prop(into)]
save_result: Signal<Option<Result<ApiResponse<NaiveDate>, ServerFnError>>>,
day: RwSignal<NaiveDate>, day: RwSignal<NaiveDate>,
price: WriteSignal<Decimal>, price: WriteSignal<Decimal>,
slots: WriteSignal<Vec<String>>, slots: WriteSignal<Vec<String>>,
@@ -68,7 +69,7 @@ pub fn res_saved(
</ModalFooter> </ModalFooter>
</ModalDialog> </ModalDialog>
</div> </div>
} }.into_any()
}, },
ApiResponse::Error(err) => { ApiResponse::Error(err) => {
view! { view! {
@@ -88,7 +89,7 @@ pub fn res_saved(
</ModalFooter> </ModalFooter>
</ModalDialog> </ModalDialog>
</div> </div>
} }.into_any()
} }
} }
} }
@@ -110,11 +111,11 @@ pub fn res_saved(
</ModalFooter> </ModalFooter>
</ModalDialog> </ModalDialog>
</div> </div>
} }.into_any()
} }
} }
} else { } else {
view! {<div></div>} view! {<div></div>}.into_any()
} }
}} }}
} }
+1 -1
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::locales::trl; use crate::locales::trl;
use crate::pages::closing_days::ClosingDays; use crate::pages::closing_days::ClosingDays;
use crate::pages::company_info::CompanyInfo; use crate::pages::company_info::CompanyInfo;
+9 -9
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ApiResponse, ResSumWithItems}; use crate::backend::data::{ApiResponse, ResSumWithItems};
use crate::backend::reservation::get_next_reservations; use crate::backend::reservation::get_next_reservations;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -9,9 +9,9 @@ use crate::pages::new_reservations::CancelDialog;
#[component] #[component]
pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView { pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView {
let res = create_blocking_resource( move || app_opener.visible() || cancel_opener.visible(), move |_| get_next_reservations()); let res = Resource::new_blocking( move || app_opener.visible() || cancel_opener.visible(), move |_| get_next_reservations());
let menu = MenuOpener::new(); let menu = MenuOpener::new();
let reservation = create_rw_signal(ResSumWithItems::default()); let reservation = RwSignal::new(ResSumWithItems::default());
view! { view! {
<CancelDialog opener=cancel_opener reservation=reservation.read_only()/> <CancelDialog opener=cancel_opener reservation=reservation.read_only()/>
@@ -22,7 +22,7 @@ pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener)
{move || { {move || {
res.get().map(|r| match r { res.get().map(|r| match r {
Err(e) => { Err(e) => {
view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}} view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}.into_any()}
Ok(r) => { match r { Ok(r) => { match r {
ApiResponse::Data(r) => { ApiResponse::Data(r) => {
view! { view! {
@@ -40,7 +40,7 @@ pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener)
let:item> let:item>
{item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/> {item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/>
</For> </For>
{trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email}</a>", "{data.customer.phone}<br/> {trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email.clone()}</a>", "{data.customer.phone}<br/>
{move || { {move || {
let note = data.summary.note.clone(); let note = data.summary.note.clone();
let show = note.is_some() && !note.clone().unwrap().is_empty(); let show = note.is_some() && !note.clone().unwrap().is_empty();
@@ -51,7 +51,7 @@ pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener)
} }
}} }}
{trl("Price: ")}{data.summary.price.to_string()}<br/> {trl("Price: ")}{data.summary.price.to_string()}<br/>
<table width="100%"> <table style="width: 100%"><tbody>
<tr> <tr>
<td width="100%"></td> <td width="100%"></td>
<td> <td>
@@ -72,15 +72,15 @@ pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener)
</div> </div>
</td> </td>
</tr> </tr>
</table> </tbody></table>
<hr/> <hr/>
} }
}} }}
</For> </For>
</div> </div>
} }.into_any()
} }
ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}}} ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}.into_any()}}
} }
}) })
}} }}
+2 -2
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::User; use crate::backend::data::User;
use crate::backend::user::DeleteUser; use crate::backend::user::DeleteUser;
use crate::components::data_form::QuestionDialog; use crate::components::data_form::QuestionDialog;
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
pub fn user_delete(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView { pub fn user_delete(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView {
let del_user = create_server_action::<DeleteUser>(); let del_user = ServerAction::<DeleteUser>::new();
view! { view! {
<QuestionDialog opener=opener action=del_user title="Delete user"> <QuestionDialog opener=opener action=del_user title="Delete user">
+2 -2
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::user::CreateUser; use crate::backend::user::CreateUser;
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -6,7 +6,7 @@ use crate::locales::trl;
#[component] #[component]
pub fn user_edit(opener: DialogOpener) -> impl IntoView { pub fn user_edit(opener: DialogOpener) -> impl IntoView {
let create_usr = create_server_action::<CreateUser>(); let create_usr = ServerAction::<CreateUser>::new();
view! { view! {
<DataForm opener=opener action=create_usr title="Create user"> <DataForm opener=opener action=create_usr title="Create user">
//<input type="hidden" value={move || company.get().id()} name="company[id]"/> //<input type="hidden" value={move || company.get().id()} name="company[id]"/>
+14 -14
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ApiResponse, User}; use crate::backend::data::{ApiResponse, User};
use crate::backend::user::get_users; use crate::backend::user::get_users;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -16,10 +16,10 @@ pub fn users() -> impl IntoView {
let profile_editor = DialogOpener::new(); let profile_editor = DialogOpener::new();
let pwd_dialog = DialogOpener::new(); let pwd_dialog = DialogOpener::new();
let delete_dialog = DialogOpener::new(); let delete_dialog = DialogOpener::new();
let users = create_blocking_resource( let users = Resource::new_blocking(
move || editor.visible() || profile_editor.visible() || delete_dialog.visible(), move |_| {get_users()}); move || editor.visible() || profile_editor.visible() || delete_dialog.visible(), move |_| {get_users()});
let (usr, set_usr) = create_signal::<Vec<User>>(vec![]); let (usr, set_usr) = signal::<Vec<User>>(vec![]);
let (profile, set_profile) = create_signal(User::default()); let (profile, set_profile) = signal(User::default());
let is_wide = use_media_query("(min-width: 500px)"); let is_wide = use_media_query("(min-width: 500px)");
view! { view! {
@@ -35,8 +35,8 @@ pub fn users() -> impl IntoView {
<thead> <thead>
<tr> <tr>
<th>{trl("Username")}</th> <th>{trl("Username")}</th>
{move || if is_wide.get() {view! {<th>{trl("Full name")}</th>}} {move || if is_wide.get() {view! {<th>{trl("Full name")}</th>}.into_any()}
else {view! {<th></th>}} } else {view! {<th></th>}.into_any()} }
<th>{trl("Admin")}</th> <th>{trl("Admin")}</th>
<th>{trl("Actions")}</th> <th>{trl("Actions")}</th>
</tr> </tr>
@@ -50,7 +50,7 @@ pub fn users() -> impl IntoView {
e.to_string() e.to_string()
}; };
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=4>{trl(&err)}</td></tr></tbody>}} <tr><td colspan=4>{trl(&err)}</td></tr></tbody>}.into_any()}
Ok(u) => { Ok(u) => {
match u { match u {
ApiResponse::Data(u) => { ApiResponse::Data(u) => {
@@ -66,11 +66,11 @@ pub fn users() -> impl IntoView {
let full_name = user.full_name.clone().unwrap_or("".to_string()); let full_name = user.full_name.clone().unwrap_or("".to_string());
view! { view! {
<tr> <tr>
<td>{&user.login}</td> <td>{user.login.clone()}</td>
{move || if is_wide.get() {view! {<td>{full_name.clone()}</td>}} {move || if is_wide.get() {view! {<td>{full_name.clone()}</td>}.into_any()}
else {view! {<td></td>}} } else {view! {<td></td>}.into_any()} }
<td>{if user.admin {view! {<i class="bx bx-check"></i>}} <td>{if user.admin {view! {<i class="bx bx-check"></i>}.into_any()}
else {view! {<i></i>}}}</td> else {view! {<i></i>}.into_any()}}</td>
<td> <td>
<div class="dropdown"> <div class="dropdown">
<button type="button" class="btn p-0 dropdown-toggle hide-arrow" <button type="button" class="btn p-0 dropdown-toggle hide-arrow"
@@ -101,10 +101,10 @@ pub fn users() -> impl IntoView {
</tr> </tr>
} }
}/></tbody> }/></tbody>
} }.into_any()
} }
ApiResponse::Error(e) => {view! {<tbody class="table-border-bottom-0"> ApiResponse::Error(e) => {view! {<tbody class="table-border-bottom-0">
<tr><td colspan=4>{trl(&e)}</td></tr></tbody>}} <tr><td colspan=4>{trl(&e)}</td></tr></tbody>}.into_any()}
} }
} }
}) })
+4 -4
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
@@ -13,9 +13,9 @@ pub struct Validator {
impl Validator { impl Validator {
pub fn new() -> Self { pub fn new() -> Self {
let (valid, set_valid) = create_signal(true); let (valid, set_valid) = signal(true);
//let (message, set_message) = create_signal(None); //let (message, set_message) = signal(None);
let (messages, set_messages) = create_signal(None); let (messages, set_messages) = signal(None);
Self { Self {
//message, //message,
//set_message, //set_message,