Compare commits
39 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
3b16cce62a | |
|
|
50bc1f4eb5 | |
|
|
e59f849abd | |
|
|
d156cbfa98 | |
|
|
fe8056179b | |
|
|
904cd4315c | |
|
|
e9edd3e82a | |
|
|
8032019807 | |
|
|
682ea672c4 | |
|
|
f7e643ffbd | |
|
|
fd2ec59d33 | |
|
|
c5100f31e5 | |
|
|
d09b280061 | |
|
|
6bdf37e91a | |
|
|
6552b1248f | |
|
|
a8ca9bfafe | |
|
|
1589ebfd37 | |
|
|
c9dd17ae14 | |
|
|
462716633e | |
|
|
939f87d820 | |
|
|
b39dde34e4 | |
|
|
4ff14e6fa1 | |
|
|
d31c47853e | |
|
|
e3e7e4442b | |
|
|
8b1cb60ef1 | |
|
|
ea0d26e4dd | |
|
|
2ead3f79c3 | |
|
|
567ba5da75 | |
|
|
0cd8f47dd5 | |
|
|
9bbee11289 | |
|
|
c182cb47e8 | |
|
|
417875300f | |
|
|
dc8a5db7f9 | |
|
|
3ba76fdb39 | |
|
|
4b7d6ec52a | |
|
|
0326c0c27a | |
|
|
f362fa8c93 | |
|
|
a590bf114a | |
|
|
d110c64e7a |
|
|
@ -1,4 +1,4 @@
|
||||||
FROM mcr.microsoft.com/devcontainers/rust:1-1-bookworm
|
FROM mcr.microsoft.com/devcontainers/rust:latest
|
||||||
|
|
||||||
# Include lld linker to improve build times either by using environment variable
|
# Include lld linker to improve build times either by using environment variable
|
||||||
# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml).
|
# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml).
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,8 @@ backend/target
|
||||||
backend/db
|
backend/db
|
||||||
backend/id_rsa
|
backend/id_rsa
|
||||||
backend/id_rsa.pub
|
backend/id_rsa.pub
|
||||||
|
backend/sqlite3.env
|
||||||
|
backend/.env
|
||||||
|
backend/prod.env
|
||||||
|
backend/test.env
|
||||||
|
backend/dev.env
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,6 @@
|
||||||
"now.instance.host.url": "",
|
"now.instance.host.url": "",
|
||||||
"now.instance.OAuth.client.id": "",
|
"now.instance.OAuth.client.id": "",
|
||||||
"now.instance.user.password": "",
|
"now.instance.user.password": "",
|
||||||
"now.instance.OAuth.client.secret": ""
|
"now.instance.OAuth.client.secret": "",
|
||||||
|
"postman.settings.dotenv-detection-notification-visibility": false
|
||||||
}
|
}
|
||||||
37
README.md
37
README.md
|
|
@ -1,3 +1,38 @@
|
||||||
# Jean-Marie family website
|
# Jean-Marie family website
|
||||||
|
|
||||||
The Jean-Marie family website is a place to share information about family activities
|
The Jean-Marie family website is a place to share information about family activities
|
||||||
|
|
||||||
|
Postgresql server setup and configuration
|
||||||
|
- Create new debian container
|
||||||
|
- 2GB RAM
|
||||||
|
- 8G Storage
|
||||||
|
- link to ZFS storage pool
|
||||||
|
- Update packages
|
||||||
|
- apt update && apt upgrade -y && apt autoremove -y
|
||||||
|
- Install current postgresql source
|
||||||
|
- apt install -y postgresql-common
|
||||||
|
- /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
|
||||||
|
- apt install -y postgresql
|
||||||
|
- Move cluster to ZFS
|
||||||
|
- Stop postgresql
|
||||||
|
- systemctl stop postgresql
|
||||||
|
- Create new directory for cluster in ZFS
|
||||||
|
- from ZFS server create world writable directory
|
||||||
|
- Move current cluster to new location
|
||||||
|
- from ZFS server ensure ownership of all files in moved directory is postgres
|
||||||
|
- Point to new location
|
||||||
|
- nano /etc/postgresql/<version>/main/postgresql.conf
|
||||||
|
- data_directory = '<new location>'
|
||||||
|
- Restart postgresql
|
||||||
|
- systemctl start postgresql
|
||||||
|
- Allow external access
|
||||||
|
- Edit pg_hba.conf
|
||||||
|
- Change host all address to local network
|
||||||
|
- Edit postgresql.conf
|
||||||
|
- Change listen_adress to '*'
|
||||||
|
- Change password
|
||||||
|
- su postgres
|
||||||
|
- psql
|
||||||
|
- alter user postgres with password '<new password>';
|
||||||
|
- \q
|
||||||
|
- exit
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
DATABASE_URL=sqlite://db/db.sqlite3
|
|
||||||
GOOGLE_CLIENT_ID=735264084619-clsmvgdqdmum4rvrcj0kuk28k9agir1c.apps.googleusercontent.com
|
|
||||||
GOOGLE_CLIENT_SECRET=L6uI7FQGoMJd-ay1HO_iGJ6M
|
|
||||||
SMTP_SERVER_NAME=mailout.easymail.ca
|
|
||||||
SMTP_SERVER_PORT=587
|
|
||||||
EMAIL_USERNAME=admin@jean-marie.ca
|
|
||||||
EMAIL_PASSWORD=Cj6wX8^JivPD
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,10 +7,10 @@ edition = "2021"
|
||||||
|
|
||||||
# Update all dependencies with `cargo upgrade -i allow && cargo update`
|
# Update all dependencies with `cargo upgrade -i allow && cargo update`
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7.6" }
|
axum = { version = "0.8.1" }
|
||||||
axum_session = { version = "0.14.2" }
|
axum_session = { version = "0.16.0" }
|
||||||
axum-server = { version = "0.7.1" }
|
axum-server = { version = "0.7.2" }
|
||||||
axum-extra = { version = "0.9.4", features = ["cookie-private", "typed-header"] }
|
axum-extra = { version = "0.10.0", features = ["cookie-private", "typed-header"] }
|
||||||
askama = "0.12.0"
|
askama = "0.12.0"
|
||||||
askama_axum = "0.4.0"
|
askama_axum = "0.4.0"
|
||||||
headers = "0.4"
|
headers = "0.4"
|
||||||
|
|
@ -23,9 +23,13 @@ oauth2 = "4.4"
|
||||||
http = "1.1"
|
http = "1.1"
|
||||||
tower-http = { version = "0.6.1", features = ["full"] }
|
tower-http = { version = "0.6.1", features = ["full"] }
|
||||||
chrono = { version = "0.4.38", features = ["serde"] }
|
chrono = { version = "0.4.38", features = ["serde"] }
|
||||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros"] }
|
sqlx = { version = "0.8", features = ["postgres", "sqlite","runtime-tokio", "macros", "chrono", "uuid"] }
|
||||||
uuid = { version = "1.10", features = ["v4"] }
|
uuid = { version = "1.10", features = ["v4"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
constant_time_eq = "0.3"
|
constant_time_eq = "0.3"
|
||||||
reqwest = "0.12"
|
reqwest = "0.12"
|
||||||
lettre = "0.11.10"
|
lettre = "0.11.10"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
hex = "0.4.3"
|
||||||
|
async-trait = "0.1.89"
|
||||||
|
filters = "0.4.0"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
For a kiosk machine that needs to always auto-login, here's the most practical approach for your Rust/Axum stack:
|
||||||
|
|
||||||
|
## Recommended Solution: Device-Specific Token Authentication
|
||||||
|
|
||||||
|
### 1. Database Schema
|
||||||
|
```sql
|
||||||
|
-- Add to your PostgreSQL database
|
||||||
|
CREATE TABLE kiosk_devices (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
device_name VARCHAR(255) NOT NULL,
|
||||||
|
device_token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_accessed TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT true
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Axum Route Handler
|
||||||
|
```rust
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::{Html, Redirect},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AutoLoginQuery {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auto_login(
|
||||||
|
Query(params): Query<AutoLoginQuery>,
|
||||||
|
State(app_state): State<AppState>, // Your app state with DB pool
|
||||||
|
) -> Result<Redirect, StatusCode> {
|
||||||
|
// Validate the device token
|
||||||
|
let device = sqlx::query!(
|
||||||
|
"SELECT id, device_name FROM kiosk_devices
|
||||||
|
WHERE device_token = $1 AND is_active = true",
|
||||||
|
params.token
|
||||||
|
)
|
||||||
|
.fetch_optional(&app_state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
match device {
|
||||||
|
Some(device) => {
|
||||||
|
// Update last accessed
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE kiosk_devices SET last_accessed = CURRENT_TIMESTAMP WHERE id = $1",
|
||||||
|
device.id
|
||||||
|
)
|
||||||
|
.execute(&app_state.db)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// Set session/cookie and redirect to dashboard
|
||||||
|
Ok(Redirect::to("/kiosk-dashboard"))
|
||||||
|
}
|
||||||
|
None => Err(StatusCode::UNAUTHORIZED),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Generate Device Token
|
||||||
|
```rust
|
||||||
|
use uuid::Uuid;
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
|
fn generate_device_token() -> String {
|
||||||
|
let uuid = Uuid::new_v4();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(uuid.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())[..32].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store this in your database for the kiosk
|
||||||
|
async fn register_kiosk_device(db: &PgPool, device_name: &str) -> Result<String, sqlx::Error> {
|
||||||
|
let token = generate_device_token();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO kiosk_devices (device_name, device_token) VALUES ($1, $2)",
|
||||||
|
device_name,
|
||||||
|
token
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Kiosk Setup
|
||||||
|
Configure the kiosk machine to:
|
||||||
|
|
||||||
|
**Option A: Browser Homepage**
|
||||||
|
Set browser homepage to: `https://yoursite.com/auto-login?token=YOUR_GENERATED_TOKEN`
|
||||||
|
|
||||||
|
**Option B: Desktop Shortcut**
|
||||||
|
Create a desktop shortcut with the auto-login URL
|
||||||
|
|
||||||
|
**Option C: Startup Script**
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# startup.sh - runs on boot
|
||||||
|
sleep 10 # wait for network
|
||||||
|
chromium-browser --kiosk --no-sandbox "https://yoursite.com/auto-login?token=YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Enhanced Security (Optional)
|
||||||
|
If you want additional security:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AutoLoginQuery {
|
||||||
|
token: String,
|
||||||
|
timestamp: Option<i64>,
|
||||||
|
signature: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auto_login_with_signature(
|
||||||
|
Query(params): Query<AutoLoginQuery>,
|
||||||
|
// ... other params
|
||||||
|
) -> Result<Redirect, StatusCode> {
|
||||||
|
// Verify timestamp (within 5 minutes)
|
||||||
|
if let (Some(timestamp), Some(signature)) = (params.timestamp, params.signature) {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
if (now - timestamp).abs() > 300 { // 5 minutes
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature (HMAC with shared secret)
|
||||||
|
let expected_sig = create_signature(¶ms.token, timestamp);
|
||||||
|
if signature != expected_sig {
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of validation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Session Management
|
||||||
|
After successful auto-login, set a session cookie:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tower_cookies::{Cookie, Cookies};
|
||||||
|
|
||||||
|
async fn auto_login(
|
||||||
|
Query(params): Query<AutoLoginQuery>,
|
||||||
|
cookies: Cookies,
|
||||||
|
// ... other params
|
||||||
|
) -> Result<Redirect, StatusCode> {
|
||||||
|
// ... validation logic ...
|
||||||
|
|
||||||
|
if let Some(device) = device {
|
||||||
|
// Set session cookie
|
||||||
|
let session_cookie = Cookie::build(("kiosk_session", device.id.to_string()))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.max_age(time::Duration::hours(24))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
cookies.add(session_cookie);
|
||||||
|
|
||||||
|
Ok(Redirect::to("/kiosk-dashboard"))
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Route Configuration
|
||||||
|
```rust
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/auto-login", get(auto_login))
|
||||||
|
.route("/kiosk-dashboard", get(kiosk_dashboard))
|
||||||
|
// ... other routes
|
||||||
|
.layer(CookieManagerLayer::new())
|
||||||
|
.with_state(app_state);
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach gives you:
|
||||||
|
- Secure device authentication
|
||||||
|
- Easy kiosk setup
|
||||||
|
- Audit trail of access
|
||||||
|
- Ability to revoke access
|
||||||
|
- No user interaction required
|
||||||
|
|
||||||
|
The kiosk will automatically authenticate and access your application whenever it starts up or navigates to the URL.
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
CREATE TABLE "oauth2_state_storage" (
|
|
||||||
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"csrf_state" text NOT NULL,
|
|
||||||
"pkce_code_verifier" text NOT NULL,
|
|
||||||
"return_url" text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "user_sessions" (
|
|
||||||
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"user_id" integer NOT NULL,
|
|
||||||
"session_token_p1" text NOT NULL,
|
|
||||||
"session_token_p2" text NOT NULL,
|
|
||||||
"created_at" integer NOT NULL,
|
|
||||||
"expires_at" integer NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "users" (
|
|
||||||
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"created_at" integer NOT NULL,
|
|
||||||
"created_by" integer NOT NULL,
|
|
||||||
"updated_at" integer NOT NULL,
|
|
||||||
"updated_by" integer NOT NULL,
|
|
||||||
"email" text NOT NULL UNIQUE,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"family_name" text NOT NULL,
|
|
||||||
"given_name" text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS roles (
|
|
||||||
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"created_at" integer NOT NULL,
|
|
||||||
"created_by" integer NOT NULL,
|
|
||||||
"updated_at" integer NOT NULL,
|
|
||||||
"updated_by" integer NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"description" TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_roles (
|
|
||||||
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"created_at" integer NOT NULL,
|
|
||||||
"created_by" integer NOT NULL,
|
|
||||||
"updated_at" integer NOT NULL,
|
|
||||||
"updated_by" integer NOT NULL,
|
|
||||||
"user_id" integer NOT NULL,
|
|
||||||
"role_id" integer NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
create TABLE IF NOT EXISTS role_permissions (
|
|
||||||
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"created_at" integer NOT NULL,
|
|
||||||
"created_by" integer NOT NULL,
|
|
||||||
"updated_at" integer NOT NULL,
|
|
||||||
"updated_by" integer NOT NULL,
|
|
||||||
"role_id" integer NOT NULL,
|
|
||||||
"item" text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
-- Add down migration script here
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('1', '0', '0', '0', '0', 'public', 'Users with only anonymous access');
|
|
||||||
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('2', '0', '0', '0', '0', 'normal', 'Users with no elevated privileges');
|
|
||||||
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('3', '0', '0', '0', '0', 'editor', 'Users with basic elevated privileges');
|
|
||||||
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('4', '0', '0', '0', '0', 'admin', 'Users with full administrative privileges');
|
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
-- Add down migration script here
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
-- Role permissions
|
|
||||||
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('1', '0', '0', '0', '0', '1', '/');
|
|
||||||
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('2', '0', '0', '0', '0', '1', '/login');
|
|
||||||
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('3', '0', '0', '0', '0', '1', '/logout');
|
|
||||||
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('4', '0', '0', '0', '0', '2', '/dashboard');
|
|
||||||
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('5', '0', '0', '0', '0', '2', '/profile');
|
|
||||||
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('6', '0', '0', '0', '0', '4', '/useradmin');
|
|
||||||
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('7', '0', '0', '0', '0', '4', '/users');
|
|
||||||
|
|
||||||
-- First user is an admin
|
|
||||||
INSERT INTO "main"."user_roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "user_id", "role_id") VALUES ('2', '0', '0', '0', '0', '1', '4');
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- Add down migration script here
|
|
||||||
-- Delete role records
|
|
||||||
DELETE FROM "main"."roles" WHERE "id" = '5';
|
|
||||||
|
|
||||||
-- Delete permission records
|
|
||||||
DELETE FROM "main"."role_permissions" WHERE "id" = '8';
|
|
||||||
|
|
||||||
-- Delete user role records
|
|
||||||
DELETE FROM "main"."user_roles" WHERE "role_id" = '5';
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
-- Add roles for calendar
|
|
||||||
INSERT INTO "main"."roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "name", "description") VALUES ('5', '0', '0', '0', '0', 'calendar', 'Users with access to the calendar');
|
|
||||||
|
|
||||||
-- Add permissions for calendar
|
|
||||||
INSERT INTO "main"."role_permissions" ("id", "created_at", "created_by", "updated_at", "updated_by", "role_id", "item") VALUES ('8', '0', '0', '0', '0', '5', '/cottagecalendar');
|
|
||||||
|
|
||||||
-- Add user roles for calendar
|
|
||||||
INSERT INTO "main"."user_roles" ("id", "created_at", "created_by", "updated_at", "updated_by", "user_id", "role_id") VALUES ('1', '0', '0', '0', '0', '1', '5');
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- Add down migration script here
|
|
||||||
drop table if exists `wishlist_items`;
|
|
||||||
|
|
||||||
delete from `role_permissions` where id = 9;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
CREATE TABLE
|
|
||||||
`wishlist_items` (
|
|
||||||
`id` integer not null primary key autoincrement,
|
|
||||||
`created_at` INTEGER not null default CURRENT_TIMESTAMP,
|
|
||||||
`created_by` ineger null,
|
|
||||||
`updated_at` INTEGER null default CURRENT_TIMESTAMP,
|
|
||||||
`updated_by` integer null,
|
|
||||||
`user_id` INTEGER null,
|
|
||||||
`item` varchar(255) null,
|
|
||||||
`item_url` varchar(255) null,
|
|
||||||
`purchased_by` INTEGER null,
|
|
||||||
unique (`id`)
|
|
||||||
);
|
|
||||||
|
|
||||||
insert into `role_permissions` (`created_at`, `created_by`, `id`, `item`, `role_id`, `updated_at`, `updated_by`) values ('0', '0', '9', '/wishlist', '2', '0', '0')
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- Add down migration script here
|
|
||||||
alter table wishlist_items drop column received_at;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
alter table wishlist_items add column received_at integer null;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-- Add down migration script here
|
|
||||||
|
|
||||||
drop table if exists `calendar_events`;
|
|
||||||
drop table if exists `calendar_event_types`;
|
|
||||||
drop table if exists `calendar`;
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
-- Add up migration script here
|
|
||||||
|
|
||||||
-- Calendars
|
|
||||||
-- 1 - Cottage
|
|
||||||
-- 2 - Family tree
|
|
||||||
create table calendar (
|
|
||||||
id integer not null primary key autoincrement,
|
|
||||||
created_at integer not null default CURRENT_TIMESTAMP,
|
|
||||||
created_by integer not null default 0,
|
|
||||||
updated_at integer null default CURRENT_TIMESTAMP,
|
|
||||||
updated_by integer not null default 0,
|
|
||||||
name varchar(255) not null
|
|
||||||
)
|
|
||||||
|
|
||||||
-- Event types
|
|
||||||
-- 1 - Rental
|
|
||||||
-- 2 - Life event
|
|
||||||
create table calendar_event_types (
|
|
||||||
id integer not null primary key autoincrement,
|
|
||||||
created_at integer not null default CURRENT_TIMESTAMP,
|
|
||||||
created_by integer not null default 0,
|
|
||||||
updated_at integer null default CURRENT_TIMESTAMP,
|
|
||||||
updated_by integer not null default 0,
|
|
||||||
name varchar(255) not null
|
|
||||||
)
|
|
||||||
|
|
||||||
create table calendar_events (
|
|
||||||
id integer not null primary key autoincrement,
|
|
||||||
created_at integer not null default CURRENT_TIMESTAMP,
|
|
||||||
created_by integer not null default 0,
|
|
||||||
updated_at integer null default CURRENT_TIMESTAMP,
|
|
||||||
updated_by integer not null default 0,
|
|
||||||
calendar_id integer not null,
|
|
||||||
event_type_id integer not null,
|
|
||||||
title varchar(255) not null,
|
|
||||||
description varchar(255) null,
|
|
||||||
start_time integer null,
|
|
||||||
end_time integer null,
|
|
||||||
repeat_type integer not null default 0, -- 0 - None, 1 - Daily, 2 - Weekly, 3 - Monthly, 4 - Yearly, 5 - Day of week, 6 - Day of month
|
|
||||||
repeat_interval integer not null default 0,
|
|
||||||
celebrate boolean not null default 0
|
|
||||||
|
|
||||||
foreign key (calendar_id) references calendar(id),
|
|
||||||
foreign key (event_type_id) references calendar_event_types(id)
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Drop Postgres tables
|
||||||
|
drop table if exists oauth2_state_storage;
|
||||||
|
drop table if exists user_sessions;
|
||||||
|
drop table if exists users;
|
||||||
|
drop table if exists roles;
|
||||||
|
drop table if exists user_roles;
|
||||||
|
drop table if exists role_permissions;
|
||||||
|
drop table if exists wishlist_items;
|
||||||
|
drop table if exists gift_exchange;
|
||||||
|
drop table if exists gift_exchange_participants;
|
||||||
|
drop table if exists calendar;
|
||||||
|
drop table if exists calendar_event_types;
|
||||||
|
drop table if exists calendar_events;
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
-- Create Postgres tables
|
||||||
|
create table if not exists oauth2_state_storage (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
csrf_state text NOT NULL,
|
||||||
|
pkce_code_verifier text NOT NULL,
|
||||||
|
return_url text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists user_sessions (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
session_token_p1 text NOT NULL,
|
||||||
|
session_token_p2 text NOT NULL,
|
||||||
|
created_at timestamp NOT NULL,
|
||||||
|
expires_at timestamp NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists users (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp NOT NULL default now(),
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
updated_at timestamp NOT NULL default now(),
|
||||||
|
updated_by uuid NOT NULL,
|
||||||
|
email text NOT NULL UNIQUE,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
family_name text NOT NULL,
|
||||||
|
given_name text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
create table IF NOT EXISTS roles (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp NOT NULL default now(),
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
updated_at timestamp NOT NULL default now(),
|
||||||
|
updated_by uuid NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
create table IF NOT EXISTS user_roles (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp NOT NULL default now(),
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
updated_at timestamp NOT NULL default now(),
|
||||||
|
updated_by uuid NOT NULL,
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
role_id uuid NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists unique_user_role on user_roles(user_id, role_id);
|
||||||
|
|
||||||
|
create table IF NOT EXISTS role_permissions (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp NOT NULL default now(),
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
updated_at timestamp NOT NULL default now(),
|
||||||
|
updated_by uuid NOT NULL,
|
||||||
|
role_id uuid NOT NULL,
|
||||||
|
item text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists wishlist_items (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp not null default now(),
|
||||||
|
created_by uuid null,
|
||||||
|
updated_at timestamp null default now(),
|
||||||
|
updated_by uuid null,
|
||||||
|
user_id uuid null,
|
||||||
|
item varchar(512) null,
|
||||||
|
item_url varchar(1024) null,
|
||||||
|
purchased_by uuid null,
|
||||||
|
received_at timestamp null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists gift_exchange (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp not null default now(),
|
||||||
|
created_by uuid not null,
|
||||||
|
updated_at timestamp not null default now(),
|
||||||
|
updated_by uuid not null,
|
||||||
|
"name" varchar(255) not null,
|
||||||
|
exchange_date timestamp not null,
|
||||||
|
"status" INTEGER not null default 0
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists gift_exchange_participants (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp not null default now(),
|
||||||
|
created_by uuid not null,
|
||||||
|
updated_at timestamp not null default now(),
|
||||||
|
updated_by uuid not null,
|
||||||
|
exchange_id uuid not null,
|
||||||
|
participant_id uuid not null,
|
||||||
|
gifter_id uuid not null
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Calendars
|
||||||
|
-- 1 - Cottage
|
||||||
|
-- 2 - Family tree
|
||||||
|
create table if not exists calendar (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp not null default now(),
|
||||||
|
created_by uuid not null,
|
||||||
|
updated_at timestamp null default now(),
|
||||||
|
updated_by uuid not null,
|
||||||
|
"name" varchar(255) not null
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Event types
|
||||||
|
-- 1 - Rental
|
||||||
|
-- 2 - Life event
|
||||||
|
create table if not exists calendar_event_types (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp not null default now(),
|
||||||
|
created_by uuid not null,
|
||||||
|
updated_at timestamp null default now(),
|
||||||
|
updated_by uuid not null,
|
||||||
|
"name" varchar(255) not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists calendar_events (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp not null default now(),
|
||||||
|
created_by uuid not null,
|
||||||
|
updated_at timestamp null default now(),
|
||||||
|
updated_by uuid not null,
|
||||||
|
calendar_id uuid not null,
|
||||||
|
event_type_id uuid not null,
|
||||||
|
title varchar(255) not null,
|
||||||
|
"description" varchar(255) null,
|
||||||
|
start_time timestamp null,
|
||||||
|
end_time timestamp null,
|
||||||
|
repeat_type integer not null default 0,
|
||||||
|
-- 0 - None, 1 - Daily, 2 - Weekly, 3 - Monthly, 4 - Yearly, 5 - Day of week, 6 - Day of month
|
||||||
|
repeat_interval integer not null default 0,
|
||||||
|
celebrate boolean not null default true
|
||||||
|
);
|
||||||
|
|
||||||
|
do $$
|
||||||
|
|
||||||
|
declare user_uuid uuid := gen_random_uuid();
|
||||||
|
|
||||||
|
begin -- Initial user
|
||||||
|
insert into users (
|
||||||
|
id,
|
||||||
|
"name",
|
||||||
|
created_by,
|
||||||
|
updated_by,
|
||||||
|
email,
|
||||||
|
family_name,
|
||||||
|
given_name
|
||||||
|
)
|
||||||
|
values
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
'admin',
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
'admin@jean-marie.ca',
|
||||||
|
'',
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Initial roles
|
||||||
|
INSERT INTO
|
||||||
|
roles (created_by, updated_by, "name", "description")
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
'public',
|
||||||
|
'Users with only anonymous access'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
roles (created_by, updated_by, "name", "description")
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
'normal',
|
||||||
|
'Users with no elevated privileges'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
roles (created_by, updated_by, "name", "description")
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
'editor',
|
||||||
|
'Users with basic elevated privileges'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
roles (created_by, updated_by, "name", "description")
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
'admin',
|
||||||
|
'Users with full administrative privileges'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
roles (created_by, updated_by, "name", "description")
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
'calendar',
|
||||||
|
'Users with access to the calendar'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Initial permissions
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'public'
|
||||||
|
),
|
||||||
|
'/'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'public'
|
||||||
|
),
|
||||||
|
'/login'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'public'
|
||||||
|
),
|
||||||
|
'/logout'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'normal'
|
||||||
|
),
|
||||||
|
'/dashboard'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'normal'
|
||||||
|
),
|
||||||
|
'/profile'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'admin'
|
||||||
|
),
|
||||||
|
'/useradmin'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'admin'
|
||||||
|
),
|
||||||
|
'/users'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'calendar'
|
||||||
|
),
|
||||||
|
'/calendar'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'normal'
|
||||||
|
),
|
||||||
|
'/wishlist'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
role_permissions (created_by, updated_by, role_id, item)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
user_uuid,
|
||||||
|
user_uuid,
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
roles
|
||||||
|
WHERE
|
||||||
|
"name" = 'normal'
|
||||||
|
),
|
||||||
|
'/giftexchange'
|
||||||
|
);
|
||||||
|
|
||||||
|
end $$;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Remove person level tables
|
||||||
|
ALTER TABLE if exists users
|
||||||
|
drop column if exists person_id;
|
||||||
|
|
||||||
|
drop table if exists people;
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- Add login table
|
||||||
|
create table if not exists people (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid (),
|
||||||
|
created_at timestamp without time zone NOT NULL DEFAULT now(),
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
updated_at timestamp without time zone NOT NULL DEFAULT now(),
|
||||||
|
updated_by uuid NOT NULL,
|
||||||
|
email text NOT NULL unique,
|
||||||
|
name text NOT NULL,
|
||||||
|
family_name text NOT NULL,
|
||||||
|
given_name text NOT NULL,
|
||||||
|
CONSTRAINT people_pkey PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE if exists users
|
||||||
|
ADD COLUMN IF NOT EXISTS person_id uuid REFERENCES people (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Copy accounts(users) to profiles(people)
|
||||||
|
insert into people (created_by, updated_by, email, name, family_name, given_name)
|
||||||
|
select created_by, updated_by, email, name, family_name, given_name from users
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- Link accounts to profiles
|
||||||
|
update users u set person_id = p.id from people p where p.email = u.email;
|
||||||
|
|
||||||
|
-- Move wishlist items from accounts to profiles
|
||||||
|
update wishlist_items wi set user_id = p.person_id from users p where p.id = wi.user_id;
|
||||||
|
|
||||||
|
-- Copy normal role from accounts to profiles
|
||||||
|
insert into user_roles (created_at, created_by, updated_at, updated_by, user_id, role_id)
|
||||||
|
select ur.created_at, ur.created_by, ur.updated_at, ur.updated_by, u.person_id, ur.role_id from user_roles ur join roles r on r.id = ur.role_id join users u on u.id = ur.user_id where r.name = 'normal'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Remove column from calendar_events
|
||||||
|
ALTER TABLE if exists calendar_events
|
||||||
|
drop column if exists state;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Add column to calendar_events
|
||||||
|
ALTER TABLE if exists calendar_events
|
||||||
|
ADD COLUMN IF NOT EXISTS state character varying(25);
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Remove default calendar and event types
|
||||||
|
DELETE FROM calendar WHERE name = 'Cottage';
|
||||||
|
DELETE FROM calendar WHERE name = 'Family tree';
|
||||||
|
|
||||||
|
DELETE FROM calendar_event_types WHERE name = 'Reservation';
|
||||||
|
DELETE FROM calendar_event_types WHERE name = 'Life event';
|
||||||
|
|
||||||
|
-- Remove constraints
|
||||||
|
ALTER TABLE public.calendar DROP CONSTRAINT unique_name;
|
||||||
|
ALTER TABLE public.calendar_event_types DROP CONSTRAINT unique_name;
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
-- Add required constraints
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Check if the constraint 'unique_name' already exists
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'unique_calendar_name'
|
||||||
|
AND conrelid = 'public.calendar'::regclass -- Specify table to narrow down
|
||||||
|
) THEN
|
||||||
|
-- If it doesn't exist, add the constraint
|
||||||
|
ALTER TABLE IF EXISTS public.calendar
|
||||||
|
ADD CONSTRAINT unique_calendar_name UNIQUE (name);
|
||||||
|
RAISE NOTICE 'Constraint unique_calendar_name added to table calendar.';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Constraint unique_calendar_name already exists on table calendar.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Check if the constraint 'unique_name' already exists
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'unique_calendar_et_name'
|
||||||
|
AND conrelid = 'public.calendar_event_types'::regclass -- Specify table to narrow down
|
||||||
|
) THEN
|
||||||
|
-- If it doesn't exist, add the constraint
|
||||||
|
ALTER TABLE IF EXISTS public.calendar_event_types
|
||||||
|
ADD CONSTRAINT unique_calendar_et_name UNIQUE (name);
|
||||||
|
RAISE NOTICE 'Constraint unique_calendar_et_name added to table calendar_event_types.';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Constraint unique_calendar_et_name already exists on table calendar_event_types.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Add default calendar and event types
|
||||||
|
insert into calendar (created_by, updated_by, name)
|
||||||
|
select id, id, 'Cottage' from users where email = 'admin@jean-marie.ca'
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
insert into calendar (created_by, updated_by, name)
|
||||||
|
select id, id, 'Family tree' from users where email = 'admin@jean-marie.ca'
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name)
|
||||||
|
select id, id, 'Reservation' from users where email = 'admin@jean-marie.ca'
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name)
|
||||||
|
select id, id, 'Life event' from users where email = 'admin@jean-marie.ca'
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Add down migration script here
|
||||||
|
ALTER TABLE if exists calendar
|
||||||
|
drop column if exists colour;
|
||||||
|
|
||||||
|
ALTER TABLE if exists calendar_event_types
|
||||||
|
drop column if exists colour;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Add up migration script here
|
||||||
|
ALTER TABLE if exists calendar
|
||||||
|
ADD COLUMN IF NOT EXISTS colour character varying(50);
|
||||||
|
|
||||||
|
ALTER TABLE if exists calendar_event_types
|
||||||
|
ADD COLUMN IF NOT EXISTS colour character varying(50);
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- Add down migration script here
|
||||||
|
ALTER TABLE if exists calendar_event_types
|
||||||
|
drop column if exists state;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Check if the constraint 'unique_calendar_et_name' already exists
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'unique_calendar_et_name'
|
||||||
|
AND conrelid = 'public.calendar_event_types'::regclass -- Specify table to narrow down
|
||||||
|
) THEN
|
||||||
|
-- If it exists, drop the constraint
|
||||||
|
ALTER TABLE IF EXISTS public.calendar_event_types
|
||||||
|
DROP CONSTRAINT unique_calendar_et_name;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Constraint unique_calendar_et_name dropped from table calendar_event_types.';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Constraint unique_calendar_et_name doesn''t exist on table calendar_event_types.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Add the constraint
|
||||||
|
ALTER TABLE IF EXISTS public.calendar_event_types
|
||||||
|
ADD CONSTRAINT unique_calendar_et_name UNIQUE (name);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Constraint unique_calendar_et_name added to table calendar_event_types.';
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- Add up migration script here
|
||||||
|
ALTER TABLE if exists calendar_event_types
|
||||||
|
ADD COLUMN IF NOT EXISTS state character varying(25);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Check if the constraint 'unique_calendar_et_name' already exists
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'unique_calendar_et_name'
|
||||||
|
AND conrelid = 'public.calendar_event_types'::regclass -- Specify table to narrow down
|
||||||
|
) THEN
|
||||||
|
-- If it exists, drop the constraint
|
||||||
|
ALTER TABLE IF EXISTS public.calendar_event_types
|
||||||
|
DROP CONSTRAINT unique_calendar_et_name;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Add the constraint
|
||||||
|
ALTER TABLE IF EXISTS public.calendar_event_types
|
||||||
|
ADD CONSTRAINT unique_calendar_et_name UNIQUE (name, state);
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- Truncate tables
|
||||||
|
TRUNCATE TABLE calendar_event_types, calendar;
|
||||||
|
|
||||||
|
-- Add default calendar and event types
|
||||||
|
insert into calendar (created_by, updated_by, name, colour)
|
||||||
|
select id, id, 'Cottage', 'blue' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar (created_by, updated_by, name, colour)
|
||||||
|
select id, id, 'Family tree', 'brown' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name, state, colour)
|
||||||
|
select id, id, 'Reservation', 'Requested', 'purple' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name, state, colour)
|
||||||
|
select id, id, 'Reservation', 'Approved', 'green' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name, state, colour)
|
||||||
|
select id, id, 'Reservation', 'Confirmed', 'blue' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name, state, colour)
|
||||||
|
select id, id, 'Reservation', 'Tentative', 'light-purple' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name, state, colour)
|
||||||
|
select id, id, 'Reservation', 'Rejected', 'red' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name, state, colour)
|
||||||
|
select id, id, 'Reservation', 'Cancelled', 'light-red' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name, state, colour)
|
||||||
|
select id, id, 'Life event', 'Birthday', 'green' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name, state, colour)
|
||||||
|
select id, id, 'Life event', 'Anniversary', 'orange' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
||||||
|
insert into calendar_event_types (created_by, updated_by, name, state, colour)
|
||||||
|
select id, id, 'Life event', 'Other', 'light-orange' from users where email = 'admin@jean-marie.ca';
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Delete old permissions
|
||||||
|
delete from role_permissions where item like 'calendar%';
|
||||||
|
|
||||||
|
-- Remove requester column
|
||||||
|
ALTER TABLE if exists calendar_events
|
||||||
|
DROP COLUMN IF EXISTS requester_id uuid;
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- Add requester_id to calendar_events
|
||||||
|
ALTER TABLE if exists calendar_events
|
||||||
|
ADD COLUMN IF NOT EXISTS requester_id uuid;
|
||||||
|
|
||||||
|
-- Copy created_by to requester_id for all current records
|
||||||
|
UPDATE calendar_events
|
||||||
|
SET requester_id = created_by;
|
||||||
|
|
||||||
|
-- Add rbac data
|
||||||
|
insert into role_permissions (item, created_by, updated_by, role_id)
|
||||||
|
select 'calendar:personal:*', u.id, u.id, r.id from users u, roles r where email = 'admin@jean-marie.ca' and r.name = 'normal';
|
||||||
|
|
||||||
|
insert into role_permissions (item, created_by, updated_by, role_id)
|
||||||
|
select 'calendar:read:*', u.id, u.id, r.id from users u, roles r where email = 'admin@jean-marie.ca' and r.name = 'calendar';
|
||||||
|
|
||||||
|
insert into role_permissions (item, created_by, updated_by, role_id)
|
||||||
|
select 'calendar:admin:*', u.id, u.id, r.id from users u, roles r where email = 'admin@jean-marie.ca' and r.name = 'admin';
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Delete all fingerprint items
|
||||||
|
drop table if exists devices;
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- Create devices table
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id uuid PRIMARY KEY default gen_random_uuid(),
|
||||||
|
created_at timestamp NOT NULL default now(),
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
updated_at timestamp NOT NULL default now(),
|
||||||
|
updated_by uuid NOT NULL,
|
||||||
|
fingerprint_hash VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
device_name VARCHAR(255),
|
||||||
|
first_seen TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
last_seen TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_fingerprint_hash ON devices(fingerprint_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen);
|
||||||
|
|
||||||
|
-- Create function to automatically update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Create trigger to automatically update updated_at
|
||||||
|
DROP TRIGGER IF EXISTS update_devices_updated_at ON devices;
|
||||||
|
CREATE TRIGGER update_devices_updated_at
|
||||||
|
BEFORE UPDATE ON devices
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE if exists kiosk_devices;
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
CREATE TABLE kiosk_devices (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
device_name VARCHAR(255) NOT NULL,
|
||||||
|
device_token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_accessed TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT true
|
||||||
|
);
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use askama_axum::{IntoResponse, Response};
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Query, State},
|
||||||
response::{Html, Redirect},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
Extension,
|
Extension, Form,
|
||||||
};
|
};
|
||||||
|
use chrono::{Days, TimeDelta};
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use sqlx::SqlitePool;
|
use rbac::RbacService;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::{postgres::PgRow, Error, FromRow, PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middlewares::is_authorized,
|
middlewares::is_authorized,
|
||||||
user::{get_user_roles_display, UserData},
|
rbac,
|
||||||
|
user::{get_user_roles_display, AccountData},
|
||||||
};
|
};
|
||||||
|
|
||||||
struct HtmlTemplate<T>(T);
|
struct HtmlTemplate<T>(T);
|
||||||
|
|
@ -31,45 +36,595 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
||||||
#[template(path = "cottagecalendar.html")]
|
pub struct Calendar {
|
||||||
struct CottageCalendarTemplate {
|
id: Uuid,
|
||||||
logged_in: bool,
|
|
||||||
name: String,
|
name: String,
|
||||||
user_roles: Vec<crate::user::UserRolesDisplay>,
|
colour: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cottagecalendar(
|
#[derive(Template)]
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
#[template(path = "calendar.html")]
|
||||||
State(db_pool): State<SqlitePool>,
|
struct CalendarTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
user: AccountData,
|
||||||
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
|
calendars: Vec<Calendar>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn calendar(
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
Extension(rbac): Extension<RbacService>,
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
// Is the user logged in?
|
||||||
let logged_in = user_name.is_some();
|
let logged_in = user_data.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
let mut is_authorized = false;
|
||||||
|
|
||||||
let user_id = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
if is_authorized("/cottagecalendar", user_data, db_pool.clone()).await {
|
if !rbac.has_permission(userid, "calendar:admin:*").await {
|
||||||
// Get user roles
|
if !rbac.has_permission(userid, "calendar:read:*").await {
|
||||||
let user_roles = get_user_roles_display(user_id, &db_pool.clone()).await;
|
if !rbac.has_permission(userid, "calendar:personal:*").await {
|
||||||
|
} else {
|
||||||
|
is_authorized = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
is_authorized = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
is_authorized = true;
|
||||||
|
}
|
||||||
|
|
||||||
let template = CottageCalendarTemplate {
|
if is_authorized {
|
||||||
logged_in,
|
// Get user roles
|
||||||
name,
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
user_roles,
|
|
||||||
};
|
let calendars: Vec<Calendar> = get_calendars(db_pool.clone()).await;
|
||||||
HtmlTemplate(template).into_response()
|
|
||||||
|
let template = CalendarTemplate {
|
||||||
|
logged_in,
|
||||||
|
user,
|
||||||
|
user_roles,
|
||||||
|
calendars,
|
||||||
|
};
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_next_event(db_pool: &SqlitePool) -> Option<String> {
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
let next_event = sqlx::query_as::<_, (String, String)>(
|
pub struct EventParams {
|
||||||
"SELECT date, title FROM events ORDER BY date ASC LIMIT 1",
|
start: String,
|
||||||
)
|
end: String,
|
||||||
.fetch_one(db_pool)
|
}
|
||||||
.await;
|
|
||||||
|
|
||||||
next_event.map(|(date, title)| format!("{} - {}", date, title))
|
pub async fn get_calendars(db_pool: PgPool) -> Vec<Calendar> {
|
||||||
}
|
let calendars: Vec<Calendar> = sqlx::query_as(r#"select id, name, colour from calendar"#)
|
||||||
|
.fetch_all(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
calendars
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_event(event_id: Uuid, db_pool: &PgPool) -> String {
|
||||||
|
// Set default events
|
||||||
|
let mut eventstring: String = "[]".to_string();
|
||||||
|
|
||||||
|
let event: Result<PgRow, Error> = Ok(sqlx::query(
|
||||||
|
r#"select to_json(json_build_object(
|
||||||
|
'title', ce.title,
|
||||||
|
'start', ce.start_time,
|
||||||
|
'end', ce.end_time,
|
||||||
|
'allDay', false,
|
||||||
|
'backgroundColor', cet.colour))
|
||||||
|
from calendar_events ce
|
||||||
|
join calendar_event_types cet on cet.id = ce.event_type_id
|
||||||
|
where ce.id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_one(db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
|
||||||
|
if let Ok(json_string) = event {
|
||||||
|
if let Ok(stringevents) = json_string.try_get_raw(0).map(|v| v.as_str().unwrap_or("")) {
|
||||||
|
//println!("Event: {:?}", stringevents);
|
||||||
|
eventstring = stringevents.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventstring
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_events(
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
Query(params): Query<EventParams>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
Extension(rbac): Extension<RbacService>,
|
||||||
|
) -> String {
|
||||||
|
//println!("Paramters: {:?}", params);
|
||||||
|
// Is the user logged in?
|
||||||
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
|
// Set default events
|
||||||
|
let mut eventstring: String = "[]".to_string();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// User is logged in
|
||||||
|
//println!("User is logged in");
|
||||||
|
|
||||||
|
// Extract the user data.
|
||||||
|
let _user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
let personid = user_data
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.person_id.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Set empty query string
|
||||||
|
let mut query = r#""#;
|
||||||
|
|
||||||
|
if !rbac.has_permission(userid, "calendar:admin:*").await {
|
||||||
|
if !rbac.has_permission(userid, "calendar:read:*").await {
|
||||||
|
if !rbac.has_permission(userid, "calendar:personal:*").await {
|
||||||
|
} else {
|
||||||
|
query = r#"select to_json(json_agg(jbo.val))
|
||||||
|
from (select json_build_object(
|
||||||
|
'id', ce.id,
|
||||||
|
'title', ce.title,
|
||||||
|
'start', ce.start_time,
|
||||||
|
'end', ce.end_time,
|
||||||
|
'allDay', false,
|
||||||
|
'backgroundColor', cet.colour)
|
||||||
|
from calendar_events ce
|
||||||
|
join calendar c on c.id = ce.calendar_id
|
||||||
|
join calendar_event_types cet on cet.id = ce.event_type_id
|
||||||
|
where ce.celebrate = true
|
||||||
|
and c.name = 'Cottage'
|
||||||
|
and start_time > $1
|
||||||
|
and start_time < $2
|
||||||
|
and ce.created_by = $3
|
||||||
|
union all
|
||||||
|
select json_build_object(
|
||||||
|
'id', ce.id,
|
||||||
|
'title', 'In use',
|
||||||
|
'start', ce.start_time,
|
||||||
|
'end', ce.end_time,
|
||||||
|
'allDay', false,
|
||||||
|
'backgroundColor', cet.colour)
|
||||||
|
from calendar_events ce
|
||||||
|
join calendar c on c.id = ce.calendar_id
|
||||||
|
join calendar_event_types cet on cet.id = ce.event_type_id
|
||||||
|
where ce.celebrate = true
|
||||||
|
and c.name = 'Cottage'
|
||||||
|
and start_time > $1
|
||||||
|
and start_time < $2
|
||||||
|
and ce.created_by != $3) as jbo(val)"#;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
query = r#"select to_json(json_agg(json_build_object(
|
||||||
|
'id', ce.id,
|
||||||
|
'title', ce.title,
|
||||||
|
'start', ce.start_time,
|
||||||
|
'end', ce.end_time,
|
||||||
|
'allDay', false,
|
||||||
|
'backgroundColor', cet.colour)))
|
||||||
|
from calendar_events ce
|
||||||
|
join calendar c on c.id = ce.calendar_id
|
||||||
|
join calendar_event_types cet on cet.id = ce.event_type_id
|
||||||
|
where ce.celebrate = true
|
||||||
|
and c.name = 'Cottage'
|
||||||
|
and start_time > $1
|
||||||
|
and start_time < $2"#;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
query = r#"select to_json(json_agg(json_build_object(
|
||||||
|
'id', ce.id,
|
||||||
|
'title', ce.title,
|
||||||
|
'start', ce.start_time,
|
||||||
|
'end', ce.end_time,
|
||||||
|
'allDay', false,
|
||||||
|
'backgroundColor', cet.colour)))
|
||||||
|
from calendar_events ce
|
||||||
|
join calendar c on c.id = ce.calendar_id
|
||||||
|
join calendar_event_types cet on cet.id = ce.event_type_id
|
||||||
|
where ce.celebrate = true
|
||||||
|
and c.name = 'Cottage'
|
||||||
|
and start_time > $1
|
||||||
|
and start_time < $2"#;
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.len() > 0 {
|
||||||
|
// User is authorized
|
||||||
|
//println!("User is authorized");
|
||||||
|
|
||||||
|
// Get requested calendar events from database
|
||||||
|
let events = sqlx::query(query)
|
||||||
|
.bind(chrono::DateTime::parse_from_rfc3339(¶ms.start).unwrap())
|
||||||
|
.bind(chrono::DateTime::parse_from_rfc3339(¶ms.end).unwrap())
|
||||||
|
.bind(personid)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
//println!("Events: {:?}", events);
|
||||||
|
|
||||||
|
if let Ok(json_string) = events {
|
||||||
|
if let Ok(stringevents) =
|
||||||
|
json_string.try_get_raw(0).map(|v| v.as_str().unwrap_or(""))
|
||||||
|
{
|
||||||
|
//println!("PgValue: {:?}", stringevents);
|
||||||
|
eventstring = stringevents.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventstring
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
pub struct Event {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
pub created_by: uuid::Uuid,
|
||||||
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
|
pub updated_by: uuid::Uuid,
|
||||||
|
pub calendar_id: uuid::Uuid,
|
||||||
|
pub event_type_id: uuid::Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub state: String,
|
||||||
|
pub start_time: chrono::NaiveDateTime,
|
||||||
|
pub end_time: chrono::NaiveDateTime,
|
||||||
|
pub celebrate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
pub struct EventCreate {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub state: String,
|
||||||
|
pub calendar_id: uuid::Uuid,
|
||||||
|
pub event_type_id: uuid::Uuid,
|
||||||
|
pub start_time: String,
|
||||||
|
pub end_time: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_event(
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
Extension(rbac): Extension<RbacService>,
|
||||||
|
Form(event): Form<EventCreate>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if is_authorized("/calendar", user_data.clone(), db_pool.clone()).await {
|
||||||
|
let fmt = "%Y-%m-%d";
|
||||||
|
let start_date = chrono::NaiveDate::parse_from_str(&event.start_time, fmt).unwrap();
|
||||||
|
let end_date = chrono::NaiveDate::parse_from_str(&event.end_time, fmt)
|
||||||
|
.unwrap()
|
||||||
|
.checked_sub_days(Days::new(1))
|
||||||
|
.unwrap();
|
||||||
|
let start_datetime = start_date.and_hms_opt(14, 0, 0).unwrap();
|
||||||
|
let end_datetime = end_date.and_hms_opt(10, 0, 0).unwrap();
|
||||||
|
|
||||||
|
let start_dt = start_datetime.and_utc();
|
||||||
|
let end_dt = end_datetime.and_utc();
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"INSERT INTO calendar_events (created_by, updated_by, calendar_id, event_type_id, title, description, state, start_time, end_time)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"#)
|
||||||
|
.bind(user_data.as_ref().unwrap().id)// Created by current user
|
||||||
|
.bind(user_data.as_ref().unwrap().id) // Updated by current user
|
||||||
|
.bind(event.calendar_id)
|
||||||
|
.bind(event.event_type_id)
|
||||||
|
.bind(event.title)
|
||||||
|
.bind(event.description)
|
||||||
|
.bind(event.state)
|
||||||
|
.bind(start_dt)
|
||||||
|
.bind(end_dt)
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("Error creating event: {}", e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirect_url = format!("/calendar");
|
||||||
|
Redirect::to(&redirect_url).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
pub struct NewRequest {
|
||||||
|
pub start: String,
|
||||||
|
pub end: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new_request(
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
Extension(rbac): Extension<RbacService>,
|
||||||
|
request: axum::http::Request<axum::body::Body>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Is the user logged in?
|
||||||
|
let logged_in = user_data.is_some();
|
||||||
|
let mut is_authorized = false;
|
||||||
|
|
||||||
|
// Set default events
|
||||||
|
let mut eventstring: String = "[]".to_string();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
let _user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
let personid = user_data
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.person_id.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if !rbac.has_permission(userid, "calendar:admin:*").await {
|
||||||
|
if !rbac.has_permission(userid, "calendar:read:*").await {
|
||||||
|
if !rbac.has_permission(userid, "calendar:personal:*").await {
|
||||||
|
} else {
|
||||||
|
is_authorized = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
is_authorized = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
is_authorized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_authorized {
|
||||||
|
let (_parts, body) = request.into_parts();
|
||||||
|
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
||||||
|
let body_str = String::from_utf8(bytes.to_vec()).unwrap();
|
||||||
|
//println!("Body: {}", body_str);
|
||||||
|
|
||||||
|
let params: NewRequest = serde_json::from_str(&body_str).unwrap();
|
||||||
|
|
||||||
|
let fmt = "%Y-%m-%d";
|
||||||
|
let start_date = chrono::NaiveDate::parse_from_str(¶ms.start, fmt).unwrap();
|
||||||
|
let end_date = chrono::NaiveDate::parse_from_str(¶ms.end, fmt)
|
||||||
|
.unwrap()
|
||||||
|
.checked_sub_days(Days::new(1))
|
||||||
|
.unwrap();
|
||||||
|
let start_datetime = start_date.and_hms_opt(14, 0, 0).unwrap();
|
||||||
|
let end_datetime = end_date.and_hms_opt(10, 0, 0).unwrap();
|
||||||
|
|
||||||
|
let event = sqlx::query_scalar::<_, uuid::Uuid>(
|
||||||
|
r#"insert into calendar_events (created_by, updated_by, calendar_id, event_type_id, title, start_time, end_time)
|
||||||
|
select p.id as created_by,
|
||||||
|
p.id as updated_by,
|
||||||
|
c.id as calendar_id,
|
||||||
|
cet.id as event_type_id,
|
||||||
|
p.given_name as title,
|
||||||
|
$1 as start_time,
|
||||||
|
$2 as end_time
|
||||||
|
from calendar c,
|
||||||
|
calendar_event_types cet,
|
||||||
|
people p
|
||||||
|
where c.name = 'Cottage'
|
||||||
|
and cet.name = 'Reservation'
|
||||||
|
and cet.state = 'Requested'
|
||||||
|
and p.id = $3
|
||||||
|
returning id"#
|
||||||
|
)
|
||||||
|
.bind(start_datetime)
|
||||||
|
.bind(end_datetime)
|
||||||
|
.bind(personid)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error creating event: {}", e),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
//println!("Event: {:#?}", &event.unwrap());
|
||||||
|
|
||||||
|
let event_id = event.clone();
|
||||||
|
|
||||||
|
eventstring = get_event(event_id.unwrap(), &db_pool).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventstring
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_event(
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
Extension(rbac): Extension<RbacService>,
|
||||||
|
request: axum::http::Request<axum::body::Body>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Is the user logged in?
|
||||||
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
|
// Set default events
|
||||||
|
let mut eventstring: String = "[]".to_string();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
let _user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
let personid = user_data
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.person_id.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if rbac.has_permission(userid, "calendar:*:*").await {
|
||||||
|
let (_parts, body) = request.into_parts();
|
||||||
|
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
||||||
|
let body_str = String::from_utf8(bytes.to_vec()).unwrap();
|
||||||
|
// println!("Body: {}", body_str);
|
||||||
|
|
||||||
|
let v: Value = serde_json::from_str(&body_str).unwrap();
|
||||||
|
|
||||||
|
let fmt = "%Y-%m-%d";
|
||||||
|
|
||||||
|
let start_date = chrono::NaiveDate::parse_from_str(v["start"].as_str().unwrap(), fmt).unwrap();
|
||||||
|
let end_date = chrono::NaiveDate::parse_from_str(v["end"].as_str().unwrap(), fmt).unwrap();
|
||||||
|
let start_datetime = start_date.and_hms_opt(14, 0, 0).unwrap();
|
||||||
|
let end_datetime = end_date.and_hms_opt(10, 0, 0).unwrap();
|
||||||
|
|
||||||
|
// Convert calendar id to UUID
|
||||||
|
let calendar_event_id = Uuid::parse_str(v["id"].as_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
// Display values to be updated
|
||||||
|
// println!("person : {}", personid);
|
||||||
|
// println!("title : {}", v["title"]);
|
||||||
|
// println!("start : {:#?}", start_datetime);
|
||||||
|
// println!("end : {:#?}", end_datetime);
|
||||||
|
|
||||||
|
let event = sqlx::query_scalar::<_, uuid::Uuid>(
|
||||||
|
r#"with cet as (select id from calendar_event_types where name = 'Reservation' and state = 'Requested')
|
||||||
|
update calendar_events
|
||||||
|
set updated_by = $1,
|
||||||
|
updated_at = now(),
|
||||||
|
event_type_id = cet.id,
|
||||||
|
title = $2,
|
||||||
|
start_time = $3,
|
||||||
|
end_time = $4
|
||||||
|
from cet
|
||||||
|
where calendar_events.id = $5
|
||||||
|
returning calendar_events.id"#
|
||||||
|
)
|
||||||
|
.bind(personid)
|
||||||
|
.bind(v["title"].as_str().unwrap())
|
||||||
|
.bind(start_datetime)
|
||||||
|
.bind(end_datetime)
|
||||||
|
.bind(calendar_event_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error creating event: {}", e),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let event_id = event.clone();
|
||||||
|
|
||||||
|
// println!("Event: {:#?}", event);
|
||||||
|
|
||||||
|
eventstring = get_event(event_id.unwrap(), &db_pool).await;
|
||||||
|
|
||||||
|
// println!("{:#?}", eventstring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventstring
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn calendar_update_event_state(
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
Extension(rbac): Extension<RbacService>,
|
||||||
|
request: axum::http::Request<axum::body::Body>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Is the user logged in?
|
||||||
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
|
// Set default events
|
||||||
|
let mut eventstring: String = "[]".to_string();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
let _user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
let personid = user_data
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.person_id.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if rbac.has_permission(userid, "calendar:*:*").await {
|
||||||
|
let (_parts, body) = request.into_parts();
|
||||||
|
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
||||||
|
let body_str = String::from_utf8(bytes.to_vec()).unwrap();
|
||||||
|
|
||||||
|
let v: Value = serde_json::from_str(&body_str).unwrap();
|
||||||
|
|
||||||
|
// Convert calendar id to UUID
|
||||||
|
let calendar_event_id = Uuid::parse_str(v["id"].as_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let event = sqlx::query_scalar::<_, uuid::Uuid>(
|
||||||
|
r#"with cet as (select id from calendar_event_types where name = $1 and state = $2)
|
||||||
|
update calendar_events
|
||||||
|
set updated_by = $3,
|
||||||
|
updated_at = now(),
|
||||||
|
event_type_id = cet.id
|
||||||
|
from cet
|
||||||
|
where calendar_events.id = $4
|
||||||
|
returning calendar_events.id"#
|
||||||
|
)
|
||||||
|
.bind(v["eventType"].as_str().unwrap())
|
||||||
|
.bind(v["state"].as_str().unwrap())
|
||||||
|
.bind(personid)
|
||||||
|
.bind(calendar_event_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error creating event: {}", e),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let event_id = event.clone();
|
||||||
|
|
||||||
|
eventstring = get_event(event_id.unwrap(), &db_pool).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventstring
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "newevent.html")]
|
||||||
|
struct EventTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
user: AccountData,
|
||||||
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
|
calendars: Vec<Calendar>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new_event(
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Is the user logged in?
|
||||||
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
if is_authorized("/calendar", user_data, db_pool.clone()).await {
|
||||||
|
// Get user roles
|
||||||
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
let calendars: Vec<Calendar> = get_calendars(db_pool.clone()).await;
|
||||||
|
|
||||||
|
let template = EventTemplate {
|
||||||
|
logged_in,
|
||||||
|
user,
|
||||||
|
user_roles,
|
||||||
|
calendars,
|
||||||
|
};
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRequestParts, State},
|
||||||
|
http::{request::Parts, StatusCode},
|
||||||
|
response::{IntoResponse, Json, Redirect}, Extension,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::{postgres::PgPool, FromRow};
|
||||||
|
|
||||||
|
use crate::{user::AccountData, AppState, routes::HtmlTemplate};
|
||||||
|
|
||||||
|
// Database models
|
||||||
|
#[derive(Debug, FromRow, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Device {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
fingerprint_hash: String,
|
||||||
|
device_name: Option<String>,
|
||||||
|
first_seen: chrono::DateTime<chrono::Utc>,
|
||||||
|
last_seen: chrono::DateTime<chrono::Utc>,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct FingerprintData {
|
||||||
|
user_agent: String,
|
||||||
|
screen_width: i32,
|
||||||
|
screen_height: i32,
|
||||||
|
timezone: String,
|
||||||
|
language: String,
|
||||||
|
platform: String,
|
||||||
|
canvas_fingerprint: String,
|
||||||
|
fonts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct UpdateDeviceRequest {
|
||||||
|
device_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprint service
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FingerprintService {
|
||||||
|
db: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FingerprintService {
|
||||||
|
pub fn new(db: PgPool) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_device(&self, fingerprint_hash: &str) -> Option<Device> {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM devices
|
||||||
|
WHERE fingerprint_hash = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(fingerprint_hash)
|
||||||
|
.fetch_optional(&self.db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computer identity extractor
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ComputerIdentity {
|
||||||
|
pub fingerprint_hash: String,
|
||||||
|
pub device: Option<Device>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for ComputerIdentity
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
AppState: FromRequestParts<S>,
|
||||||
|
{
|
||||||
|
type Rejection = (StatusCode, &'static str);
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
// Get app state
|
||||||
|
let app_state = AppState::from_request_parts(parts, state)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to get app state"))?;
|
||||||
|
|
||||||
|
// Extract fingerprint from cookie or header
|
||||||
|
let fingerprint_hash = parts
|
||||||
|
.headers
|
||||||
|
.get("x-fingerprint")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if fingerprint_hash.is_empty() {
|
||||||
|
return Ok(ComputerIdentity {
|
||||||
|
fingerprint_hash: String::new(),
|
||||||
|
device: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get device from database
|
||||||
|
let device = get_device_by_fingerprint(&app_state.db, &fingerprint_hash)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Ok(ComputerIdentity {
|
||||||
|
fingerprint_hash,
|
||||||
|
device,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
fn generate_fingerprint_hash(data: &FingerprintData) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(format!(
|
||||||
|
"{}:{}x{}:{}:{}:{}:{}:{}",
|
||||||
|
data.user_agent,
|
||||||
|
data.screen_width,
|
||||||
|
data.screen_height,
|
||||||
|
data.timezone,
|
||||||
|
data.language,
|
||||||
|
data.platform,
|
||||||
|
data.canvas_fingerprint,
|
||||||
|
data.fonts.join(",")
|
||||||
|
));
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_device_by_fingerprint(
|
||||||
|
db: &PgPool,
|
||||||
|
fingerprint_hash: &str,
|
||||||
|
) -> Result<Device, sqlx::Error> {
|
||||||
|
let result = sqlx::query_as(
|
||||||
|
r#"SELECT id, fingerprint_hash, device_name, first_seen, last_seen, user_agent
|
||||||
|
FROM devices WHERE fingerprint_hash = $1"#
|
||||||
|
)
|
||||||
|
.bind(fingerprint_hash)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_or_update_device(
|
||||||
|
db: &PgPool,
|
||||||
|
fingerprint_hash: &str,
|
||||||
|
user_agent: &str,
|
||||||
|
) -> Result<Device, sqlx::Error> {
|
||||||
|
// Try to update existing device
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"UPDATE devices SET last_seen = NOW(), user_agent = $2
|
||||||
|
WHERE fingerprint_hash = $1 RETURNING id"#
|
||||||
|
)
|
||||||
|
.bind(fingerprint_hash.to_string())
|
||||||
|
.bind(user_agent.to_string())
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.is_some() {
|
||||||
|
// Device exists, return it
|
||||||
|
return get_device_by_fingerprint(db, fingerprint_hash).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new device
|
||||||
|
let device_id = uuid::Uuid::new_v4();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO devices (id, fingerprint_hash, first_seen, last_seen, user_agent)
|
||||||
|
VALUES ($1, $2, NOW(), NOW(), $3)")
|
||||||
|
.bind(device_id)
|
||||||
|
.bind(fingerprint_hash.to_string())
|
||||||
|
.bind(user_agent.to_string())
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
get_device_by_fingerprint(db, fingerprint_hash).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template structs
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "device.html")]
|
||||||
|
struct DeviceTemplate {
|
||||||
|
device_status: String,
|
||||||
|
fingerprint_hash: String,
|
||||||
|
device: Option<Device>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "deviceadmin.html")]
|
||||||
|
struct DeviceAdminTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
user: AccountData,
|
||||||
|
devices: Vec<Device>,
|
||||||
|
named_devices_count: usize,
|
||||||
|
recent_devices_count: usize,
|
||||||
|
weekly_devices_count: usize,
|
||||||
|
fingerprint_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route handlers
|
||||||
|
pub async fn device(
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
//identity: ComputerIdentity,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Is the user logged in?
|
||||||
|
//let logged_in = user_data.is_some();
|
||||||
|
let logged_in = true;
|
||||||
|
|
||||||
|
// Set empty ComputerIdentity
|
||||||
|
let identity = ComputerIdentity {
|
||||||
|
fingerprint_hash: "somehash#&".to_string(),
|
||||||
|
device: Some(Device {id: uuid::Uuid::new_v4(), fingerprint_hash: String::new(), device_name: Some("Test Device".to_string()), first_seen: chrono::Utc::now(), last_seen: chrono::Utc::now(), user_agent: None,}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
//let user = user_data.as_ref().unwrap().clone();
|
||||||
|
//let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
let device_status = if let Some(device) = &identity.device {
|
||||||
|
format!(
|
||||||
|
"Welcome back, {}! (Last seen: {})",
|
||||||
|
device.device_name.as_deref().unwrap_or("Unknown Device"),
|
||||||
|
device.last_seen.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
)
|
||||||
|
} else if identity.fingerprint_hash.is_empty() {
|
||||||
|
"Detecting your device...".to_string()
|
||||||
|
} else {
|
||||||
|
"New device detected!".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return HtmlTemplate(DeviceTemplate {
|
||||||
|
device_status,
|
||||||
|
fingerprint_hash: identity.fingerprint_hash,
|
||||||
|
device: identity.device,
|
||||||
|
}).into_response();
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_fingerprint(
|
||||||
|
db_pool: &PgPool,
|
||||||
|
Json(fingerprint_data): Json<FingerprintData>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
let fingerprint_hash = generate_fingerprint_hash(&fingerprint_data);
|
||||||
|
|
||||||
|
match create_or_update_device(&db_pool, &fingerprint_hash, &fingerprint_data.user_agent).await {
|
||||||
|
Ok(_) => Ok(Json(serde_json::json!({
|
||||||
|
"fingerprint_hash": fingerprint_hash,
|
||||||
|
"status": "success"
|
||||||
|
}))),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Database error: {}", e);
|
||||||
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_device_name(
|
||||||
|
db_pool: &PgPool,
|
||||||
|
identity: ComputerIdentity,
|
||||||
|
Json(request): Json<UpdateDeviceRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if identity.fingerprint_hash.is_empty() {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
match sqlx::query(
|
||||||
|
r#"UPDATE devices SET device_name = $1 WHERE fingerprint_hash = $2"#)
|
||||||
|
.bind(
|
||||||
|
if request.device_name.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&request.device_name)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.bind(identity.fingerprint_hash)
|
||||||
|
.execute(db_pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok("status: success".to_string().into_response()),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Database error: {}", e);
|
||||||
|
Ok(format!("Database error: {}", e).to_string().into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn admin_devices(
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
_identity: ComputerIdentity,
|
||||||
|
) -> Result<DeviceAdminTemplate, StatusCode> {
|
||||||
|
let devices: Vec<Device> = sqlx::query_as(
|
||||||
|
r#"SELECT id, fingerprint_hash, device_name, first_seen, last_seen, user_agent
|
||||||
|
FROM devices ORDER BY last_seen DESC"#
|
||||||
|
)
|
||||||
|
.fetch_all(&db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Database error: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// User variables
|
||||||
|
let logged_in = true;
|
||||||
|
let user = AccountData::default();
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
let named_devices_count = devices.iter().filter(|d| d.device_name.is_some()).count();
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let one_day_ago = now - chrono::Duration::days(1);
|
||||||
|
let one_week_ago = now - chrono::Duration::weeks(1);
|
||||||
|
|
||||||
|
let recent_devices_count = devices.iter()
|
||||||
|
.filter(|d| d.last_seen > one_day_ago)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let weekly_devices_count = devices.iter()
|
||||||
|
.filter(|d| d.last_seen > one_week_ago)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let fingerprint_hash = String::new();
|
||||||
|
|
||||||
|
Ok(DeviceAdminTemplate {
|
||||||
|
logged_in,
|
||||||
|
user,
|
||||||
|
devices,
|
||||||
|
named_devices_count,
|
||||||
|
recent_devices_count,
|
||||||
|
weekly_devices_count,
|
||||||
|
fingerprint_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -5,10 +5,9 @@
|
||||||
// GOOGLE_CLIENT_SECRET=yyy
|
// GOOGLE_CLIENT_SECRET=yyy
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Extension, Host, Query, State},
|
extract::{Extension, Query, State}, response::{IntoResponse, Redirect}
|
||||||
response::{IntoResponse, Redirect},
|
|
||||||
};
|
};
|
||||||
use axum_extra::TypedHeader;
|
use axum_extra::{extract::Host, TypedHeader};
|
||||||
use dotenvy::var;
|
use dotenvy::var;
|
||||||
use headers::Cookie;
|
use headers::Cookie;
|
||||||
use http::{HeaderMap, HeaderValue};
|
use http::{HeaderMap, HeaderValue};
|
||||||
|
|
@ -18,15 +17,14 @@ use oauth2::{
|
||||||
TokenResponse, TokenUrl,
|
TokenResponse, TokenUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono::Utc;
|
use sqlx::PgPool;
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::user::get_useremails_by_role;
|
use crate::user::{get_useremails_by_role, AccountData};
|
||||||
use crate::email::send_emails;
|
use crate::email::send_emails;
|
||||||
|
|
||||||
use super::{AppError, UserData};
|
use super::{AppError};
|
||||||
|
|
||||||
fn get_client(hostname: String) -> Result<BasicClient, AppError> {
|
fn get_client(hostname: String) -> Result<BasicClient, AppError> {
|
||||||
let google_client_id = ClientId::new(var("GOOGLE_CLIENT_ID")?);
|
let google_client_id = ClientId::new(var("GOOGLE_CLIENT_ID")?);
|
||||||
|
|
@ -60,9 +58,9 @@ fn get_client(hostname: String) -> Result<BasicClient, AppError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
Query(mut params): Query<HashMap<String, String>>,
|
Query(mut params): Query<HashMap<String, String>>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Host(hostname): Host,
|
Host(hostname): Host,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Redirect, AppError> {
|
||||||
if user_data.is_some() {
|
if user_data.is_some() {
|
||||||
|
|
@ -91,20 +89,24 @@ pub async fn login(
|
||||||
.url();
|
.url();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO oauth2_state_storage (csrf_state, pkce_code_verifier, return_url) VALUES (?, ?, ?);",
|
"INSERT INTO oauth2_state_storage (csrf_state, pkce_code_verifier, return_url) VALUES ($1, $2, $3);"
|
||||||
)
|
)
|
||||||
.bind(csrf_state.secret())
|
.bind(csrf_state.secret())
|
||||||
.bind(pkce_code_verifier.secret())
|
.bind(pkce_code_verifier.secret())
|
||||||
.bind(return_url)
|
.bind(return_url)
|
||||||
.execute(&db_pool)
|
.execute(&db_pool)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("Error inserting into oauth2_state_storage: {}", e);
|
||||||
|
AppError::new("Error inserting into oauth2_state_storage")
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Redirect::to(authorize_url.as_str()))
|
Ok(Redirect::to(authorize_url.as_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn google_auth_return(
|
pub async fn google_auth_return(
|
||||||
Query(mut params): Query<HashMap<String, String>>,
|
Query(mut params): Query<HashMap<String, String>>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
cookie: Option<TypedHeader<Cookie>>,
|
cookie: Option<TypedHeader<Cookie>>,
|
||||||
Host(hostname): Host,
|
Host(hostname): Host,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
|
@ -121,7 +123,7 @@ pub async fn google_auth_return(
|
||||||
);
|
);
|
||||||
|
|
||||||
let query: (String, String) = sqlx::query_as(
|
let query: (String, String) = sqlx::query_as(
|
||||||
r#"DELETE FROM oauth2_state_storage WHERE csrf_state = ? RETURNING pkce_code_verifier,return_url"#,
|
r#"DELETE FROM oauth2_state_storage WHERE csrf_state = $1 RETURNING pkce_code_verifier,return_url"#,
|
||||||
)
|
)
|
||||||
.bind(state.secret())
|
.bind(state.secret())
|
||||||
.fetch_one(&db_pool)
|
.fetch_one(&db_pool)
|
||||||
|
|
@ -131,6 +133,7 @@ pub async fn google_auth_return(
|
||||||
let _return_url = query.1;
|
let _return_url = query.1;
|
||||||
let pkce_code_verifier = PkceCodeVerifier::new(pkce_code_verifier);
|
let pkce_code_verifier = PkceCodeVerifier::new(pkce_code_verifier);
|
||||||
|
|
||||||
|
|
||||||
// Exchange the code with a token.
|
// Exchange the code with a token.
|
||||||
let client = get_client(hostname)?;
|
let client = get_client(hostname)?;
|
||||||
let token_response = tokio::task::spawn_blocking(move || {
|
let token_response = tokio::task::spawn_blocking(move || {
|
||||||
|
|
@ -186,21 +189,15 @@ pub async fn google_auth_return(
|
||||||
|
|
||||||
// Check if user exists in database
|
// Check if user exists in database
|
||||||
// If not, create a new user
|
// If not, create a new user
|
||||||
let query: Result<(i64,), _> = sqlx::query_as(r#"SELECT id FROM users WHERE email=?"#)
|
let query: Result<(uuid::Uuid,), _> = sqlx::query_as(r#"SELECT id FROM users WHERE email=$1"#)
|
||||||
.bind(email.as_str())
|
.bind(email.as_str())
|
||||||
.fetch_one(&db_pool)
|
.fetch_one(&db_pool)
|
||||||
.await;
|
.await;
|
||||||
let user_id = if let Ok(query) = query {
|
let user_id = if let Ok(query) = query {
|
||||||
query.0
|
query.0
|
||||||
} else {
|
} else {
|
||||||
let now = Utc::now().timestamp();
|
// Add person
|
||||||
|
let person: (uuid::Uuid,) = sqlx::query_as(r#"INSERT INTO people (created_by, updated_by, email, name, family_name, given_name) VALUES ((SELECT id FROM users WHERE "name" = 'admin'), (SELECT id FROM users WHERE "name" = 'admin'), $1, $2, $3, $4) RETURNING id"#)
|
||||||
// Add user
|
|
||||||
let query: (i64,) = sqlx::query_as("INSERT INTO users (created_at, created_by, updated_at, updated_by, email, name, family_name, given_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
||||||
.bind(now)
|
|
||||||
.bind(0 as i64)// Created by system
|
|
||||||
.bind(now)
|
|
||||||
.bind(0 as i64) // Updated by system
|
|
||||||
.bind(email.clone())
|
.bind(email.clone())
|
||||||
.bind(name.clone())
|
.bind(name.clone())
|
||||||
.bind(family_name.clone())
|
.bind(family_name.clone())
|
||||||
|
|
@ -208,22 +205,28 @@ pub async fn google_auth_return(
|
||||||
.fetch_one(&db_pool)
|
.fetch_one(&db_pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Add user
|
||||||
|
let user: (uuid::Uuid,) = sqlx::query_as(r#"INSERT INTO users (created_by, updated_by, email, name, family_name, given_name, person_id) VALUES ((SELECT id FROM users WHERE "name" = 'admin'), (SELECT id FROM users WHERE "name" = 'admin'), $1, $2, $3, $4, $5) RETURNING id"#)
|
||||||
|
.bind(email.clone())
|
||||||
|
.bind(name.clone())
|
||||||
|
.bind(family_name.clone())
|
||||||
|
.bind(given_name.clone())
|
||||||
|
.bind(person.0)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Add public role
|
// Add public role
|
||||||
sqlx::query("INSERT INTO user_roles (created_at, created_by, updated_at, updated_by, user_id, role_id) VALUES (?, ?, ?, ?, ?, ?)")
|
sqlx::query(r#"INSERT INTO user_roles (created_by, updated_by, user_id, role_id) VALUES ((SELECT id FROM users WHERE "name" = 'admin'), (SELECT id FROM users WHERE "name" = 'admin'), $1, (SELECT id FROM roles WHERE "name" = 'public'))"#)
|
||||||
.bind(now)
|
.bind(user.0)
|
||||||
.bind(0 as i64)// Created by system
|
|
||||||
.bind(now)
|
|
||||||
.bind(0 as i64) // Updated by system
|
|
||||||
.bind(query.0)
|
|
||||||
.bind("1")
|
|
||||||
.execute(&db_pool)
|
.execute(&db_pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// send email to admin regarding new user registration
|
// send email to admin regarding new user registration
|
||||||
let recipients = get_useremails_by_role("admin".to_string(), &db_pool).await;
|
let recipients = get_useremails_by_role("admin".to_string(), &db_pool).await;
|
||||||
send_emails("Jean-Marie website - New user registration".to_string(), recipients, "A new user has registered".to_string());
|
let body = format!("A new user has registered on the website: <br> <b>Name:</b> {} <br> <b>Email:</b> {} <br> <b>Family Name:</b> {} <br> <b>Given Name:</b> {}", name, email, family_name, given_name);
|
||||||
|
send_emails("Jean-Marie website - New user registration".to_string(), recipients, body);
|
||||||
|
|
||||||
query.0
|
user.0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update session with user id or create new session
|
// Update session with user id or create new session
|
||||||
|
|
@ -242,18 +245,15 @@ pub async fn google_auth_return(
|
||||||
session_token
|
session_token
|
||||||
)
|
)
|
||||||
).map_err(|_| AppError::new("Failed to create session token header"))?;
|
).map_err(|_| AppError::new("Failed to create session token header"))?;
|
||||||
let now = Utc::now().timestamp();
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO user_sessions
|
"INSERT INTO user_sessions
|
||||||
(session_token_p1, session_token_p2, user_id, created_at, expires_at)
|
(session_token_p1, session_token_p2, user_id, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?);",
|
VALUES ($1, $2, $3, now(), now() + interval '1 day');",
|
||||||
)
|
)
|
||||||
.bind(session_token_p1)
|
.bind(session_token_p1)
|
||||||
.bind(session_token_p2)
|
.bind(session_token_p2)
|
||||||
.bind(user_id) // Set user to anonymous
|
.bind(user_id) // Set user to anonymous
|
||||||
.bind(now)
|
|
||||||
.bind(now + 60 * 60 * 24)
|
|
||||||
.execute(&db_pool)
|
.execute(&db_pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
@ -263,12 +263,12 @@ pub async fn google_auth_return(
|
||||||
|
|
||||||
pub async fn logout(
|
pub async fn logout(
|
||||||
cookie: Option<TypedHeader<Cookie>>,
|
cookie: Option<TypedHeader<Cookie>>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
if let Some(cookie) = cookie {
|
if let Some(cookie) = cookie {
|
||||||
if let Some(session_token) = cookie.get("session_token") {
|
if let Some(session_token) = cookie.get("session_token") {
|
||||||
let session_token: Vec<&str> = session_token.split('_').collect();
|
let session_token: Vec<&str> = session_token.split('_').collect();
|
||||||
let _ = sqlx::query("DELETE FROM user_sessions WHERE session_token_1 = ?")
|
let _ = sqlx::query("DELETE FROM user_sessions WHERE session_token_1 = $1")
|
||||||
.bind(session_token[0])
|
.bind(session_token[0])
|
||||||
.execute(&db_pool)
|
.execute(&db_pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Redirect},
|
||||||
|
};
|
||||||
|
use axum_extra::TypedHeader;
|
||||||
|
use headers::Cookie;
|
||||||
|
use http::{HeaderMap, HeaderValue};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AutoLoginQuery {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_device_token() -> String {
|
||||||
|
let uuid = Uuid::new_v4();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(uuid.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())[..32].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store this in your database for the kiosk
|
||||||
|
async fn register_kiosk_device(db: &PgPool, device_name: &str) -> Result<String, sqlx::Error> {
|
||||||
|
let token = generate_device_token();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO kiosk_devices (device_name, device_token) VALUES ($1, $2)",
|
||||||
|
device_name,
|
||||||
|
token
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn auto_login(
|
||||||
|
cookie: Option<TypedHeader<Cookie>>,
|
||||||
|
Query(params): Query<AutoLoginQuery>,
|
||||||
|
State(db_pool): State<PgPool>, // Your app state with DB pool
|
||||||
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
println!("Auto login token: {}", params.token);
|
||||||
|
|
||||||
|
// Validate the device token
|
||||||
|
let device = sqlx::query!(
|
||||||
|
"SELECT id, device_name FROM kiosk_devices
|
||||||
|
WHERE device_token = $1 AND is_active = true",
|
||||||
|
params.token
|
||||||
|
)
|
||||||
|
.fetch_optional(&db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let mut returnpath = "/";
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
|
||||||
|
match device {
|
||||||
|
Some(device) => {
|
||||||
|
println!("Device name: {}", device.device_name);
|
||||||
|
|
||||||
|
// Update last accessed
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE kiosk_devices SET last_accessed = CURRENT_TIMESTAMP WHERE id = $1",
|
||||||
|
device.id
|
||||||
|
)
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// Log in to kiosk account
|
||||||
|
// Set session/cookie and redirect to dashboard
|
||||||
|
returnpath = "/kiosk-dashboard"
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Log out and return to the dashboard
|
||||||
|
println!("Invalid device token");
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
if let Some(session_token) = cookie.get("session_token") {
|
||||||
|
let session_token: Vec<&str> = session_token.split('_').collect();
|
||||||
|
let _ = sqlx::query("DELETE FROM user_sessions WHERE session_token_1 = $1")
|
||||||
|
.bind(session_token[0])
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::SET_COOKIE,
|
||||||
|
HeaderValue::from_static(
|
||||||
|
"session_token=deleted; path=/; httponly; secure; samesite=strict",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
returnpath = "/";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok((headers, Redirect::to(returnpath)))
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,50 @@
|
||||||
|
use askama_axum::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
middleware,
|
extract::{Path, State}, middleware, response::{Html, IntoResponse, Redirect}, routing::{get, get_service, post}, Extension, Router
|
||||||
routing::{get, get_service},
|
|
||||||
Extension, Router,
|
|
||||||
};
|
};
|
||||||
use calendar::cottagecalendar;
|
use dotenvy::var;
|
||||||
use sqlx::migrate::Migrator;
|
use secret_gift_exchange::{giftexchange, giftexchange_save, giftexchanges};
|
||||||
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, Row, SqlitePool};
|
||||||
use std::net::SocketAddr;
|
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||||
use tower_http::services::ServeDir;
|
use std::{f32::consts::E, net::SocketAddr};
|
||||||
|
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||||
|
|
||||||
|
mod fingerprint;
|
||||||
mod calendar;
|
mod calendar;
|
||||||
mod email;
|
mod email;
|
||||||
mod error_handling;
|
mod error_handling;
|
||||||
mod google_oauth;
|
mod google_oauth;
|
||||||
mod middlewares;
|
mod middlewares;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod secret_gift_exchange;
|
||||||
mod user;
|
mod user;
|
||||||
mod wishlist;
|
mod wishlist;
|
||||||
|
mod rbac;
|
||||||
|
mod kiosk;
|
||||||
|
|
||||||
|
use fingerprint::{FingerprintService, handle_fingerprint, update_device_name, admin_devices};
|
||||||
|
use rbac::RbacService;
|
||||||
|
use calendar::{calendar, get_events, create_event, new_event, new_request};
|
||||||
use error_handling::AppError;
|
use error_handling::AppError;
|
||||||
use google_oauth::{google_auth_return, login, logout};
|
use google_oauth::{google_auth_return, login, logout};
|
||||||
use middlewares::inject_user_data;
|
use middlewares::inject_user_data;
|
||||||
use routes::{about, contact, dashboard, index, profile, user_profile, useradmin};
|
use routes::{about, contact, dashboard, index, profile, user_profile, user_profile_account, useradmin};
|
||||||
use user::{add_user_role, delete_user_role, UserData};
|
use user::{add_user_role, delete_user_role, AccountData};
|
||||||
use wishlist::{
|
use wishlist::{
|
||||||
user_wishlist, user_wishlist_add, user_wishlist_add_item, user_wishlist_bought_item,
|
user_wishlist, user_wishlist_add, user_wishlist_add_item, user_wishlist_bought_item,
|
||||||
user_wishlist_edit_item, user_wishlist_received_item, user_wishlist_save_item, wishlists,
|
user_wishlist_delete_item, user_wishlist_edit_item, user_wishlist_received_item,
|
||||||
|
user_wishlist_returned_item, user_wishlist_save_item, wishlists,
|
||||||
};
|
};
|
||||||
|
use kiosk::auto_login;
|
||||||
|
|
||||||
|
use crate::{calendar::{calendar_update_event_state, update_event}, fingerprint::{device, ComputerIdentity, Device}, routes::HtmlTemplate};
|
||||||
|
|
||||||
//use email::send_emails;
|
//use email::send_emails;
|
||||||
|
|
||||||
|
// Application state
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
struct AppState {
|
||||||
pub db_pool: SqlitePool,
|
db: PgPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -39,54 +52,106 @@ async fn main() {
|
||||||
// initialize tracing
|
// initialize tracing
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let db_pool = SqlitePoolOptions::new()
|
// Get the server settings from the env file
|
||||||
.max_connections(5)
|
let database_url = var("DATABASE_URL").expect("DATABASE_URL not set");
|
||||||
.connect("sqlite://db/db.sqlite3")
|
let db_pool = PgPoolOptions::new().connect(&database_url).await.unwrap();
|
||||||
.await;
|
|
||||||
|
|
||||||
let app_state = AppState {
|
let app_state = AppState { db: db_pool };
|
||||||
db_pool: db_pool.expect("Failed to get db_pool"),
|
|
||||||
};
|
|
||||||
|
|
||||||
static MIGRATOR: Migrator = sqlx::migrate!();
|
static MIGRATOR: Migrator = sqlx::migrate!();
|
||||||
|
|
||||||
MIGRATOR
|
MIGRATOR
|
||||||
.run(&app_state.db_pool)
|
.run(&app_state.db)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to run migrations");
|
.expect("Failed to run migrations");
|
||||||
|
|
||||||
let user_data: Option<UserData> = None;
|
// Copy from old sqlite database if it exists
|
||||||
|
if let Ok(source_db_url) = var("SOURCE_DB_URL") {
|
||||||
|
let sdb_pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&source_db_url)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
copy_database(&sdb_pool, &app_state.db).await;
|
||||||
|
} else {
|
||||||
|
println!("SOURCE_DB_URL not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rbac = RbacService::new(app_state.db.clone());
|
||||||
|
let fingerprint = FingerprintService::new(app_state.db.clone());
|
||||||
|
|
||||||
|
let user_data: Option<AccountData> = None;
|
||||||
|
|
||||||
// build our application with some routes
|
// build our application with some routes
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/cottagecalendar", get(cottagecalendar))
|
.route("/debug", get(debug))
|
||||||
.route("/dashboard", get(dashboard))
|
.route("/dashboard", get(dashboard))
|
||||||
|
|
||||||
|
// Kiosk
|
||||||
|
.route("/auto-login", get(auto_login))
|
||||||
|
//.route("/kiosk-dashboard", get(kiosk_dashboard))
|
||||||
|
|
||||||
|
//Fingerprint
|
||||||
|
//.route("/api/fingerprint", post(handle_fingerprint))
|
||||||
|
//.route("/api/device/name", post(update_device_name))
|
||||||
|
//.route("/admin/devices", get(admin_devices))
|
||||||
|
.route("/device", get(device))
|
||||||
|
|
||||||
|
// User
|
||||||
.route("/profile", get(profile))
|
.route("/profile", get(profile))
|
||||||
.route("/useradmin", get(useradmin))
|
.route("/useradmin", get(useradmin))
|
||||||
.route("/users/:user_id", get(user_profile))
|
.route("/user/{user_id}", get(user_profile))
|
||||||
.route("/roles/:user_id/:role_id/add", get(add_user_role))
|
.route("/user/{user_id}/{account_id}", get(user_profile_account))
|
||||||
|
.route("/roles/{user_id}/{role_id}/add", get(add_user_role))
|
||||||
.route(
|
.route(
|
||||||
"/roles/:user_id/:user_role_id/delete",
|
"/roles/{user_id}/{user_role_id}/delete",
|
||||||
get(delete_user_role),
|
get(delete_user_role),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Calendar
|
||||||
|
.route("/calendar", get(calendar))
|
||||||
|
.route("/calendar/getevents", get(get_events))
|
||||||
|
.route("/calendar/createevent", post(create_event))
|
||||||
|
.route("/calendar/newevent", get(new_event))
|
||||||
|
.route("/calendar/newrequest", post(new_request))
|
||||||
|
.route("/calendar/updaterequest", post(update_event))
|
||||||
|
.route("/calendar/updateeventstate", post(calendar_update_event_state))
|
||||||
|
|
||||||
|
// Wishlist
|
||||||
.route("/wishlists", get(wishlists))
|
.route("/wishlists", get(wishlists))
|
||||||
.route("/userwishlist/:user_id", get(user_wishlist))
|
.route("/userwishlist/{user_id}", get(user_wishlist))
|
||||||
.route(
|
.route(
|
||||||
"/userwishlist/add/:user_id",
|
"/userwishlist/add/{user_id}",
|
||||||
get(user_wishlist_add).post(user_wishlist_add_item),
|
get(user_wishlist_add).post(user_wishlist_add_item),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/userwishlist/edit/:item_id",
|
"/userwishlist/edit/{item_id}",
|
||||||
get(user_wishlist_edit_item).post(user_wishlist_save_item),
|
get(user_wishlist_edit_item).post(user_wishlist_save_item),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/userwishlist/bought/:user_id",
|
"/userwishlist/bought/{user_id}",
|
||||||
get(user_wishlist_bought_item),
|
get(user_wishlist_bought_item),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/userwishlist/received/:user_id",
|
"/userwishlist/received/{user_id}",
|
||||||
get(user_wishlist_received_item),
|
get(user_wishlist_received_item),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/userwishlist/delete/{item_id}",
|
||||||
|
get(user_wishlist_delete_item),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/userwishlist/returned/{item_id}",
|
||||||
|
get(user_wishlist_returned_item),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Secret Gift Exchange - Not ready for public use yet
|
||||||
|
.route("/giftexchanges", get(giftexchanges))
|
||||||
|
.route(
|
||||||
|
"/giftexchange/{giftexchange_id}",
|
||||||
|
get(giftexchange).post(giftexchange_save),
|
||||||
|
)
|
||||||
.nest_service(
|
.nest_service(
|
||||||
"/assets",
|
"/assets",
|
||||||
ServeDir::new("templates/assets")
|
ServeDir::new("templates/assets")
|
||||||
|
|
@ -99,11 +164,15 @@ async fn main() {
|
||||||
.route("/logout", get(logout))
|
.route("/logout", get(logout))
|
||||||
.route("/google_auth_return", get(google_auth_return))
|
.route("/google_auth_return", get(google_auth_return))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
app_state.db_pool.clone(),
|
app_state.db.clone(),
|
||||||
inject_user_data,
|
inject_user_data,
|
||||||
))
|
))
|
||||||
.with_state(app_state.db_pool.clone())
|
.with_state(app_state.db.clone())
|
||||||
.layer(Extension(user_data));
|
.layer(Extension(user_data))
|
||||||
|
.layer(Extension(rbac))
|
||||||
|
.layer(Extension(fingerprint))
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
;
|
||||||
|
|
||||||
// Send email indicating server has started
|
// Send email indicating server has started
|
||||||
//let recipients = get_useremails_by_role("admin".to_string(), &app_state.db_pool).await;
|
//let recipients = get_useremails_by_role("admin".to_string(), &app_state.db_pool).await;
|
||||||
|
|
@ -117,3 +186,259 @@ async fn main() {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn copy_database(sdb_pool: &SqlitePool, db_pool: &PgPool) {
|
||||||
|
// Copy users
|
||||||
|
let users = sqlx::query(
|
||||||
|
r#"select
|
||||||
|
datetime(u.created_at, 'unixepoch'),
|
||||||
|
coalesce(cb.email, 'admin@jean-marie.ca') as created_by_email,
|
||||||
|
datetime(u.updated_at, 'unixepoch'),
|
||||||
|
coalesce(ub.email, 'admin@jean-marie.ca') as updated_by_email,
|
||||||
|
u.email,
|
||||||
|
u.name,
|
||||||
|
u.family_name,
|
||||||
|
u.given_name
|
||||||
|
from users u
|
||||||
|
left join users cb on cb.id = u.created_by
|
||||||
|
left join users ub on ub.id = u.updated_by;"#,
|
||||||
|
)
|
||||||
|
.fetch_all(sdb_pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to copy users from SQLite to Postgres");
|
||||||
|
|
||||||
|
println!("\nCopying {} users", users.len());
|
||||||
|
|
||||||
|
for user in users {
|
||||||
|
if let (
|
||||||
|
Ok(created_at),
|
||||||
|
Ok(created_by),
|
||||||
|
Ok(updated_at),
|
||||||
|
Ok(updated_by),
|
||||||
|
Ok(email),
|
||||||
|
Ok(name),
|
||||||
|
Ok(family_name),
|
||||||
|
Ok(given_name),
|
||||||
|
) = (
|
||||||
|
user.try_get::<chrono::NaiveDateTime, _>(0),
|
||||||
|
user.try_get::<String, _>(1),
|
||||||
|
user.try_get::<chrono::NaiveDateTime, _>(2),
|
||||||
|
user.try_get::<String, _>(3),
|
||||||
|
user.try_get::<String, _>(4),
|
||||||
|
user.try_get::<String, _>(5),
|
||||||
|
user.try_get::<String, _>(6),
|
||||||
|
user.try_get::<String, _>(7),
|
||||||
|
) {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"insert into users (created_at, created_by, updated_at, updated_by, email, name, family_name, given_name)
|
||||||
|
values ($1, (select id from users where email =$2), $3, (select id from users where email =$4), $5, $6, $7, $8)"#
|
||||||
|
)
|
||||||
|
.bind(created_at)
|
||||||
|
.bind(created_by)
|
||||||
|
.bind(updated_at)
|
||||||
|
.bind(updated_by)
|
||||||
|
.bind(email)
|
||||||
|
.bind(name)
|
||||||
|
.bind(family_name)
|
||||||
|
.bind(given_name)
|
||||||
|
.execute(db_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
println!("Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy user roles
|
||||||
|
let user_roles = sqlx::query(
|
||||||
|
r#"select
|
||||||
|
datetime(ur.created_at, 'unixepoch'),
|
||||||
|
coalesce(cb.email, 'admin@jean-marie.ca') as created_by_email,
|
||||||
|
datetime(ur.updated_at, 'unixepoch'),
|
||||||
|
coalesce(ub.email, 'admin@jean-marie.ca') as updated_by_email,
|
||||||
|
u.email as user_email,
|
||||||
|
r.name as role_name
|
||||||
|
from user_roles ur
|
||||||
|
left join users cb on cb.id = ur.created_by
|
||||||
|
left join users ub on ub.id = ur.updated_by
|
||||||
|
join users u on u.id = ur.user_id
|
||||||
|
join roles r on r.id = ur.role_id;"#,
|
||||||
|
)
|
||||||
|
.fetch_all(sdb_pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to copy user roles from SQLite to Postgres");
|
||||||
|
|
||||||
|
println!("\nCopying {} user roles", user_roles.len());
|
||||||
|
|
||||||
|
for user_role in user_roles {
|
||||||
|
if let (
|
||||||
|
Ok(created_at),
|
||||||
|
Ok(created_by),
|
||||||
|
Ok(updated_at),
|
||||||
|
Ok(updated_by),
|
||||||
|
Ok(user_email),
|
||||||
|
Ok(role_name),
|
||||||
|
) = (
|
||||||
|
user_role.try_get::<chrono::NaiveDateTime, _>(0),
|
||||||
|
user_role.try_get::<String, _>(1),
|
||||||
|
user_role.try_get::<chrono::NaiveDateTime, _>(2),
|
||||||
|
user_role.try_get::<String, _>(3),
|
||||||
|
user_role.try_get::<String, _>(4),
|
||||||
|
user_role.try_get::<String, _>(5),
|
||||||
|
) {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"insert into user_roles (created_at, created_by, updated_at, updated_by, user_id, role_id)
|
||||||
|
values ($1, (select id from users where email=$2), $3, (select id from users where email=$4), (select id from users where email=$5), (select id from roles where name=$6))"#
|
||||||
|
)
|
||||||
|
.bind(created_at)
|
||||||
|
.bind(created_by)
|
||||||
|
.bind(updated_at)
|
||||||
|
.bind(updated_by)
|
||||||
|
.bind(user_email)
|
||||||
|
.bind(role_name)
|
||||||
|
.execute(db_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
println!("Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy wishlistitems
|
||||||
|
let wishlistitems = sqlx::query(
|
||||||
|
r#"select
|
||||||
|
datetime(wi.created_at, 'unixepoch'),
|
||||||
|
coalesce(cb.email, 'admin@jean-marie.ca') as created_by_email,
|
||||||
|
datetime(wi.updated_at, 'unixepoch'),
|
||||||
|
coalesce(ub.email, 'admin@jean-marie.ca') as updated_by_email,
|
||||||
|
u.email as user_email,
|
||||||
|
wi.item,
|
||||||
|
wi.item_url,
|
||||||
|
pb.email,
|
||||||
|
datetime(wi.received_at, 'unixepoch')
|
||||||
|
from wishlist_items wi
|
||||||
|
left join users cb on cb.id = wi.created_by
|
||||||
|
left join users ub on ub.id = wi.updated_by
|
||||||
|
left join users pb on pb.id = wi.purchased_by
|
||||||
|
join users u on u.id = wi.user_id;"#,
|
||||||
|
)
|
||||||
|
.fetch_all(sdb_pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to copy wishlistitems from SQLite to Postgres");
|
||||||
|
|
||||||
|
println!("\nCopying {} wishlistitems", wishlistitems.len());
|
||||||
|
|
||||||
|
for wishlistitem in wishlistitems {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"insert into wishlist_items (created_at, created_by, updated_at, updated_by, user_id, item, item_url, purchased_by, received_at)
|
||||||
|
values ($1, (select id from users where email=$2), $3, (select id from users where email=$4), (select id from users where email=$5), $6, $7, (select id from users where email=$8), $9)"#
|
||||||
|
)
|
||||||
|
.bind(wishlistitem.try_get::<chrono::NaiveDateTime,_>(0).unwrap())
|
||||||
|
.bind(wishlistitem.try_get::<String,_>(1).unwrap())
|
||||||
|
.bind(wishlistitem.try_get::<chrono::NaiveDateTime,_>(2).unwrap())
|
||||||
|
.bind(wishlistitem.try_get::<String,_>(3).unwrap())
|
||||||
|
.bind(wishlistitem.try_get::<String,_>(4).unwrap())
|
||||||
|
.bind(wishlistitem.try_get::<String,_>(5).unwrap())
|
||||||
|
.bind(wishlistitem.try_get::<String,_>(6).unwrap())
|
||||||
|
.bind(wishlistitem.try_get::<String,_>(7).unwrap())
|
||||||
|
.bind(wishlistitem.try_get::<chrono::NaiveDateTime,_>(8).unwrap_or_default())
|
||||||
|
.execute(db_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
println!("Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration scripts again
|
||||||
|
// Copy accounts(users) to profiles(people)
|
||||||
|
let result =sqlx::query(r#"insert into people (created_by, updated_by, email, name, family_name, given_name)
|
||||||
|
select created_by, updated_by, email, name, family_name, given_name from users
|
||||||
|
on conflict do nothing;"#)
|
||||||
|
.execute(db_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
println!("Error: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link accounts to profiles
|
||||||
|
let result = sqlx::query(r#"update users u set person_id = p.id from people p where p.email = u.email;"#)
|
||||||
|
.execute(db_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
println!("Error: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move wishlist items from accounts to profiles
|
||||||
|
let result = sqlx::query(r#"update wishlist_items wi set user_id = p.person_id from users p where p.id = wi.user_id;"#)
|
||||||
|
.execute(db_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
println!("Error: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy normal role from accounts to profiles
|
||||||
|
let result = sqlx::query(r#"insert into user_roles (created_at, created_by, updated_at, updated_by, user_id, role_id)
|
||||||
|
select ur.created_at, ur.created_by, ur.updated_at, ur.updated_by, u.person_id, ur.role_id from user_roles ur join roles r on r.id = ur.role_id join users u on u.id = ur.user_id where r.name = 'normal'
|
||||||
|
on conflict do nothing;"#)
|
||||||
|
.execute(db_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
println!("Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "debug.html")]
|
||||||
|
struct DebugTemplate {
|
||||||
|
user: AccountData,
|
||||||
|
access: bool,
|
||||||
|
connected: String,
|
||||||
|
identity: ComputerIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn debug(
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
Extension(fingerprint): Extension<FingerprintService>,
|
||||||
|
Extension(rbac): Extension<RbacService>,
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
|
// Set empty values
|
||||||
|
let mut user = AccountData::default();
|
||||||
|
let mut connected = "false".to_string();
|
||||||
|
let mut access = false;
|
||||||
|
let mut identity = ComputerIdentity {
|
||||||
|
fingerprint_hash: String::new(),
|
||||||
|
device: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
user = user_data.as_ref().unwrap().clone();
|
||||||
|
access = rbac.has_permission(user_data.as_ref().unwrap().id.clone(), "calendar:read:*").await;
|
||||||
|
} else {
|
||||||
|
user.name = "Anonymous".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if db_pool.is_closed() {
|
||||||
|
connected = "false".to_string();
|
||||||
|
} else {
|
||||||
|
connected = "true".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = DebugTemplate {
|
||||||
|
user,
|
||||||
|
access,
|
||||||
|
connected,
|
||||||
|
identity,
|
||||||
|
};
|
||||||
|
HtmlTemplate(template)
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use super::{AppError, UserData};
|
use super::{AppError, AccountData};
|
||||||
use axum::{
|
use axum::{body::Body, extract::State, http::Request, middleware::Next, response::IntoResponse};
|
||||||
body::Body,
|
|
||||||
extract::State,
|
|
||||||
http::Request,
|
|
||||||
middleware::Next,
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use axum_extra::TypedHeader;
|
use axum_extra::TypedHeader;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use headers::Cookie;
|
use headers::Cookie;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
pub async fn inject_user_data(
|
pub async fn inject_user_data(
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
cookie: Option<TypedHeader<Cookie>>,
|
cookie: Option<TypedHeader<Cookie>>,
|
||||||
mut request: Request<Body>,
|
mut request: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
|
|
@ -22,8 +16,8 @@ pub async fn inject_user_data(
|
||||||
if let Some(cookie) = cookie {
|
if let Some(cookie) = cookie {
|
||||||
if let Some(session_token) = cookie.get("session_token") {
|
if let Some(session_token) = cookie.get("session_token") {
|
||||||
let session_token: Vec<&str> = session_token.split('_').collect();
|
let session_token: Vec<&str> = session_token.split('_').collect();
|
||||||
let query: Result<(i64, i64, String), _> = sqlx::query_as(
|
let query: Result<(uuid::Uuid, chrono::NaiveDateTime, String), _> = sqlx::query_as(
|
||||||
r#"SELECT user_id,expires_at,session_token_p2 FROM user_sessions WHERE session_token_p1=?"#,
|
r#"SELECT user_id,expires_at,session_token_p2 FROM user_sessions WHERE session_token_p1=$1"#,
|
||||||
)
|
)
|
||||||
.bind(session_token[0])
|
.bind(session_token[0])
|
||||||
.fetch_one(&db_pool)
|
.fetch_one(&db_pool)
|
||||||
|
|
@ -44,16 +38,16 @@ pub async fn inject_user_data(
|
||||||
) {
|
) {
|
||||||
let id = query.0;
|
let id = query.0;
|
||||||
let expires_at = query.1;
|
let expires_at = query.1;
|
||||||
if expires_at > Utc::now().timestamp() {
|
if expires_at > Utc::now().naive_local() {
|
||||||
let row = sqlx::query_as!(
|
let row: AccountData = sqlx::query_as(
|
||||||
UserData,
|
r#"SELECT id, created_at, created_by, updated_at, updated_by, email, name, family_name, given_name, person_id FROM users WHERE id = $1"#,
|
||||||
"SELECT * FROM users WHERE id = ?",
|
)
|
||||||
id
|
.bind(id)
|
||||||
)
|
|
||||||
.fetch_one(&db_pool)
|
.fetch_one(&db_pool)
|
||||||
.await?;
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
request.extensions_mut().insert(Some(UserData {
|
request.extensions_mut().insert(Some(AccountData {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
created_by: row.created_by,
|
created_by: row.created_by,
|
||||||
|
|
@ -62,7 +56,8 @@ pub async fn inject_user_data(
|
||||||
email: row.email,
|
email: row.email,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
family_name: row.family_name,
|
family_name: row.family_name,
|
||||||
given_name: row.given_name
|
given_name: row.given_name,
|
||||||
|
person_id: row.person_id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,9 +70,9 @@ pub async fn inject_user_data(
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_authorized(path: &str, user_data: Option<UserData>, db_pool: SqlitePool) -> bool {
|
pub async fn is_authorized(path: &str, user_data: Option<AccountData>, db_pool: PgPool) -> bool {
|
||||||
if let Some(user_data) = user_data {
|
if let Some(user_data) = user_data {
|
||||||
let query: Result<(i64,), _> = match path {
|
let query: Result<(uuid::Uuid,), _> = match path {
|
||||||
"/profile" => {
|
"/profile" => {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -85,10 +80,11 @@ pub async fn is_authorized(path: &str, user_data: Option<UserData>, db_pool: Sql
|
||||||
// loop through path to find a permission
|
// loop through path to find a permission
|
||||||
let mut remaining_path = Path::new(path);
|
let mut remaining_path = Path::new(path);
|
||||||
loop {
|
loop {
|
||||||
let query: Result<(String,), _> = sqlx::query_as(r#"select r.item from role_permissions r where item = ?"#)
|
let query: Result<(String,), _> =
|
||||||
.bind(remaining_path.to_str().unwrap())
|
sqlx::query_as(r#"select r.item from role_permissions r where item = $1"#)
|
||||||
.fetch_one(&db_pool)
|
.bind(remaining_path.to_str().unwrap())
|
||||||
.await;
|
.fetch_one(&db_pool)
|
||||||
|
.await;
|
||||||
if let Ok(query) = query {
|
if let Ok(query) = query {
|
||||||
if query.0 != "" {
|
if query.0 != "" {
|
||||||
break;
|
break;
|
||||||
|
|
@ -99,7 +95,7 @@ pub async fn is_authorized(path: &str, user_data: Option<UserData>, db_pool: Sql
|
||||||
}
|
}
|
||||||
remaining_path = remaining_path.parent().unwrap();
|
remaining_path = remaining_path.parent().unwrap();
|
||||||
}
|
}
|
||||||
sqlx::query_as(r#"select u.id from role_permissions r join user_roles ur on ur.role_id = r.role_id join users u on u.id = ur.user_id where item = ? and email = ?"#)
|
sqlx::query_as(r#"select u.id from role_permissions r join user_roles ur on ur.role_id = r.role_id join users u on u.id = ur.user_id where item = $1 and email = $2"#)
|
||||||
.bind(remaining_path.to_str().unwrap())
|
.bind(remaining_path.to_str().unwrap())
|
||||||
.bind(user_data.email.as_str())
|
.bind(user_data.email.as_str())
|
||||||
.fetch_one(&db_pool)
|
.fetch_one(&db_pool)
|
||||||
|
|
@ -114,4 +110,4 @@ pub async fn is_authorized(path: &str, user_data: Option<UserData>, db_pool: Sql
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
// src/rbac.rs
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive (Clone)]
|
||||||
|
pub struct RbacService {
|
||||||
|
pool: sqlx::PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RbacService {
|
||||||
|
pub fn new(pool: sqlx::PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn has_permission(&self, user_id: Uuid, resource: &str) -> bool {
|
||||||
|
let result: Result<Vec<String>, _> = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT rp.item FROM roles r
|
||||||
|
INNER JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
INNER JOIN user_roles ur ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(patterns) => patterns.iter()
|
||||||
|
.any(|pattern| permission_matches(pattern, resource)),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wildcard permission matching (e.g., "article:edit:*" matches "article:edit:123")
|
||||||
|
fn permission_matches(pattern: &str, resource: &str) -> bool {
|
||||||
|
let pattern_segments: Vec<&str> = pattern.split(':').collect();
|
||||||
|
let resource_segments: Vec<&str> = resource.split(':').collect();
|
||||||
|
|
||||||
|
if pattern_segments.len() != resource_segments.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern_segments.iter()
|
||||||
|
.zip(resource_segments.iter())
|
||||||
|
.all(|(p, r)| p == r || *p == "*" || *r == "*")
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,23 @@
|
||||||
use askama_axum::{Response, Template};
|
use askama_axum::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::{Html, IntoResponse, Redirect},
|
response::{Html, IntoResponse, Redirect},
|
||||||
Extension,
|
Extension,
|
||||||
};
|
};
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
middlewares::is_authorized,
|
middlewares::is_authorized,
|
||||||
user::{get_other_roles_display, get_user_roles_display},
|
user::{get_other_roles_display, get_user_roles_display, AccountData, PersonData},
|
||||||
UserData,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "profile.html")]
|
#[template(path = "profile.html")]
|
||||||
struct ProfileTemplate {
|
struct ProfileTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
user: UserData,
|
|
||||||
user_roles: Vec<crate::user::UserRolesDisplay>,
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,20 +25,22 @@ struct ProfileTemplate {
|
||||||
#[template(path = "user.html")]
|
#[template(path = "user.html")]
|
||||||
struct UserProfileTemplate {
|
struct UserProfileTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
user_roles: Vec<crate::user::UserRolesDisplay>,
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
profile: UserData,
|
profile: PersonData,
|
||||||
|
profile_accounts: Vec<AccountData>,
|
||||||
|
account: AccountData,
|
||||||
profile_roles: Vec<crate::user::UserRolesDisplay>,
|
profile_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
non_profile_roles: Vec<crate::user::UserRolesDisplay>,
|
non_profile_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HtmlTemplate<T>(T);
|
pub struct HtmlTemplate<T>(pub T);
|
||||||
|
|
||||||
impl<T> IntoResponse for HtmlTemplate<T>
|
impl<T> IntoResponse for HtmlTemplate<T>
|
||||||
where
|
where
|
||||||
T: Template,
|
T: Template,
|
||||||
{
|
{
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> http::Response<axum::body::Body> {
|
||||||
match self.0.render() {
|
match self.0.render() {
|
||||||
Ok(html) => Html(html).into_response(),
|
Ok(html) => Html(html).into_response(),
|
||||||
Err(err) => (
|
Err(err) => (
|
||||||
|
|
@ -55,46 +56,72 @@ where
|
||||||
#[template(path = "index.html")]
|
#[template(path = "index.html")]
|
||||||
struct IndexTemplate {
|
struct IndexTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "dashboard.html")]
|
#[template(path = "dashboard.html")]
|
||||||
struct DashboardTemplate {
|
struct DashboardTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
user_roles: Vec<crate::user::UserRolesDisplay>,
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
|
fire_rating: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn index(
|
pub async fn index(
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let name = user_data.as_ref().map(|s| s.name.clone()).unwrap_or_default();
|
// Is the user logged in?
|
||||||
let logged_in = user_data.is_some();
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
if is_authorized("/dashboard", user_data, db_pool).await {
|
if logged_in {
|
||||||
Redirect::to("/dashboard").into_response()
|
// Extract the user data.
|
||||||
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
|
||||||
|
if is_authorized("/dashboard", user_data, db_pool).await {
|
||||||
|
Redirect::to("/dashboard").into_response()
|
||||||
|
} else {
|
||||||
|
let template = IndexTemplate { logged_in, user };
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let template = IndexTemplate { logged_in, name };
|
let template = IndexTemplate {
|
||||||
|
logged_in,
|
||||||
|
user: AccountData::default(),
|
||||||
|
};
|
||||||
HtmlTemplate(template).into_response()
|
HtmlTemplate(template).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn dashboard(
|
pub async fn dashboard(
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
// Is the user logged in?
|
||||||
let name = user_data.as_ref().map(|s| s.name.clone()).unwrap_or_default();
|
|
||||||
let logged_in = user_data.is_some();
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
if is_authorized("/dashboard", user_data, db_pool.clone()).await {
|
if logged_in {
|
||||||
// Get user roles
|
// Extract the user data.
|
||||||
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
let template = DashboardTemplate { logged_in, name, user_roles };
|
if is_authorized("/dashboard", user_data, db_pool.clone()).await {
|
||||||
HtmlTemplate(template).into_response()
|
// Get user roles
|
||||||
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
|
let fire_rating = get_seguin_fire_rating().await;
|
||||||
|
|
||||||
|
let template = DashboardTemplate {
|
||||||
|
logged_in,
|
||||||
|
user,
|
||||||
|
user_roles,
|
||||||
|
fire_rating,
|
||||||
|
};
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -102,27 +129,25 @@ pub async fn dashboard(
|
||||||
|
|
||||||
/// Handles the profile page.
|
/// Handles the profile page.
|
||||||
pub async fn profile(
|
pub async fn profile(
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Extract the user's name from the user data.
|
// Is the user logged in?
|
||||||
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
|
||||||
let name = user_data.as_ref().map(|s| s.name.clone()).unwrap_or_default();
|
|
||||||
let logged_in = user_data.is_some();
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
if logged_in {
|
if logged_in {
|
||||||
// Extract the user data.
|
// Extract the user data.
|
||||||
let user = user_data.as_ref().unwrap().clone();
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
if is_authorized("/profile", user_data, db_pool.clone()).await {
|
if is_authorized("/profile", user_data, db_pool.clone()).await {
|
||||||
// Get user roles
|
// Get user roles
|
||||||
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
// Create the profile template.
|
// Create the profile template.
|
||||||
let template = ProfileTemplate {
|
let template = ProfileTemplate {
|
||||||
logged_in,
|
logged_in,
|
||||||
name,
|
user,
|
||||||
user: user.clone(),
|
|
||||||
user_roles,
|
user_roles,
|
||||||
};
|
};
|
||||||
return HtmlTemplate(template).into_response();
|
return HtmlTemplate(template).into_response();
|
||||||
|
|
@ -135,41 +160,113 @@ pub async fn profile(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_profile(
|
pub async fn user_profile(
|
||||||
Path(user_id): Path<i64>,
|
Path(user_id): Path<uuid::Uuid>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Extract the user's name from the user data.
|
// Is the user logged in?
|
||||||
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
|
||||||
let name = user_data.as_ref().map(|s| s.name.clone()).unwrap_or_default();
|
|
||||||
let logged_in = user_data.is_some();
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
// Extract the user data.
|
if logged_in {
|
||||||
let user = sqlx::query_as!(UserData, "SELECT * FROM users WHERE id = ?", user_id)
|
// Extract the user data.
|
||||||
.fetch_one(&db_pool)
|
//let user = user_data.as_ref().unwrap().clone();
|
||||||
.await
|
//let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if is_authorized("/users", user_data, db_pool.clone()).await {
|
// Extract the user data.
|
||||||
// Get logged in user roles
|
let profile: PersonData = sqlx::query_as( "SELECT * FROM people WHERE id = $1")
|
||||||
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
.bind(user_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Get user roles
|
if is_authorized("/users", user_data, db_pool.clone()).await {
|
||||||
let profile_roles = get_user_roles_display(user_id, &db_pool.clone()).await;
|
// Get first user account
|
||||||
|
let profile_account: Uuid = sqlx::query_scalar( "SELECT id FROM users WHERE person_id = $1 LIMIT 1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Get roles user does not have
|
let redirect_url = format!("/user/{user_id}/{}", profile_account);
|
||||||
let non_profile_roles = get_other_roles_display(user_id, &db_pool.clone()).await;
|
Redirect::to(&redirect_url).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create the profile template.
|
pub async fn user_profile_account(
|
||||||
let template = UserProfileTemplate {
|
Path((user_id, account_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||||
logged_in,
|
State(db_pool): State<PgPool>,
|
||||||
name,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
user_roles,
|
) -> impl IntoResponse {
|
||||||
profile: user,
|
// Is the user logged in?
|
||||||
profile_roles,
|
let logged_in = user_data.is_some();
|
||||||
non_profile_roles,
|
|
||||||
};
|
if logged_in {
|
||||||
return HtmlTemplate(template).into_response();
|
// Extract the user data.
|
||||||
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
// Extract the user data.
|
||||||
|
let profile = sqlx::query_as( "SELECT * FROM people WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if is_authorized("/users", user_data, db_pool.clone()).await {
|
||||||
|
// Get logged in user roles
|
||||||
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
|
// Get user accounts
|
||||||
|
let profile_accounts = sqlx::query_as( r#"SELECT
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
created_by,
|
||||||
|
updated_at,
|
||||||
|
updated_by,
|
||||||
|
person_id,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
family_name,
|
||||||
|
given_name
|
||||||
|
FROM users WHERE person_id = $1 order by name"#)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Get user account
|
||||||
|
let account = sqlx::query_as( "SELECT * FROM users WHERE id = $1")
|
||||||
|
.bind(account_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Get user roles
|
||||||
|
let profile_roles = get_user_roles_display(account_id, &db_pool.clone()).await;
|
||||||
|
|
||||||
|
// Get roles user does not have
|
||||||
|
let non_profile_roles = get_other_roles_display(account_id, &db_pool.clone()).await;
|
||||||
|
|
||||||
|
// Create the profile template.
|
||||||
|
let template = UserProfileTemplate {
|
||||||
|
logged_in,
|
||||||
|
user,
|
||||||
|
user_roles,
|
||||||
|
profile,
|
||||||
|
profile_accounts,
|
||||||
|
account,
|
||||||
|
profile_roles,
|
||||||
|
non_profile_roles,
|
||||||
|
};
|
||||||
|
return HtmlTemplate(template).into_response();
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -179,37 +276,42 @@ pub async fn user_profile(
|
||||||
#[template(path = "useradmin.html")]
|
#[template(path = "useradmin.html")]
|
||||||
struct UserAdminTemplate {
|
struct UserAdminTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
users: Vec<UserData>,
|
|
||||||
user_roles: Vec<crate::user::UserRolesDisplay>,
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
|
users: Vec<PersonData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn useradmin(
|
pub async fn useradmin(
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
// Is the user logged in?
|
||||||
let logged_in = user_name.is_some();
|
let logged_in = user_data.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
|
||||||
|
|
||||||
let users = sqlx::query_as::<_, UserData>("SELECT * FROM users")
|
if logged_in {
|
||||||
.fetch_all(&db_pool)
|
// Extract the user data.
|
||||||
.await
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
.unwrap();
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
let user_id = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
let users = sqlx::query_as::<_, PersonData>("SELECT * FROM people order by name")
|
||||||
|
.fetch_all(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
if is_authorized("/useradmin", user_data, db_pool.clone()).await {
|
if is_authorized("/useradmin", user_data, db_pool.clone()).await {
|
||||||
// Get user roles
|
// Get user roles
|
||||||
let user_roles = get_user_roles_display(user_id, &db_pool.clone()).await;
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
let template = UserAdminTemplate {
|
let template = UserAdminTemplate {
|
||||||
logged_in,
|
logged_in,
|
||||||
name,
|
user,
|
||||||
users,
|
user_roles,
|
||||||
user_roles,
|
users,
|
||||||
};
|
};
|
||||||
HtmlTemplate(template).into_response()
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -219,15 +321,22 @@ pub async fn useradmin(
|
||||||
#[template(path = "about.html")]
|
#[template(path = "about.html")]
|
||||||
struct AboutTemplate {
|
struct AboutTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn about(Extension(user_data): Extension<Option<UserData>>) -> impl IntoResponse {
|
pub async fn about(Extension(user_data): Extension<Option<AccountData>>) -> impl IntoResponse {
|
||||||
let user_name = user_data.map(|s| s.name);
|
// Is the user logged in?
|
||||||
let logged_in = user_name.is_some();
|
let logged_in = user_data.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
|
||||||
|
|
||||||
let template = AboutTemplate { logged_in, name };
|
// Set empty user
|
||||||
|
let mut user = AccountData::default();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
user = user_data.as_ref().unwrap().clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = AboutTemplate { logged_in, user };
|
||||||
HtmlTemplate(template)
|
HtmlTemplate(template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,14 +344,47 @@ pub async fn about(Extension(user_data): Extension<Option<UserData>>) -> impl In
|
||||||
#[template(path = "contactus.html")]
|
#[template(path = "contactus.html")]
|
||||||
struct ContactTemplate {
|
struct ContactTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn contact(Extension(user_data): Extension<Option<UserData>>) -> impl IntoResponse {
|
pub async fn contact(Extension(user_data): Extension<Option<AccountData>>) -> impl IntoResponse {
|
||||||
let user_name = user_data.map(|s| s.name);
|
// Is the user logged in?
|
||||||
let logged_in = user_name.is_some();
|
let logged_in = user_data.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
|
||||||
|
|
||||||
let template = ContactTemplate { logged_in, name };
|
// Set empty user
|
||||||
|
let mut user = AccountData::default();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
user = user_data.as_ref().unwrap().clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = ContactTemplate { logged_in, user };
|
||||||
HtmlTemplate(template)
|
HtmlTemplate(template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_seguin_fire_rating() -> String {
|
||||||
|
let response = reqwest::get("https://www.seguin.ca/en/explore-play/firerating.aspx")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fire_rating: String;
|
||||||
|
|
||||||
|
let body = response.text().await.unwrap();
|
||||||
|
|
||||||
|
let result = body.find(r#"<img title="#);
|
||||||
|
if let Some(result) = result {
|
||||||
|
let link = body[result..].to_string();
|
||||||
|
let link_end = link.find(r#">"#);
|
||||||
|
if let Some(link_end) = link_end {
|
||||||
|
fire_rating = link[..link_end +1].to_string();
|
||||||
|
} else {
|
||||||
|
println!("not found");
|
||||||
|
fire_rating = "0".to_string();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fire_rating = "0".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
fire_rating
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
body::{self, Body},
|
||||||
|
extract::{Path, Request, State},
|
||||||
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use http::{header::CONTENT_TYPE, StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{FromRow, PgPool};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
middlewares::is_authorized,
|
||||||
|
user::{get_user_roles_display, AccountData, PersonData},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Select participants from user list
|
||||||
|
/// create group id for exchange
|
||||||
|
/// allow user to only see their recipient but whole list of participants
|
||||||
|
/// link to recipient wish list
|
||||||
|
/// button to create selections
|
||||||
|
///
|
||||||
|
/// Database schema
|
||||||
|
/// Table - gift_exchange
|
||||||
|
/// Columns - id -> number
|
||||||
|
/// - created_by -> number
|
||||||
|
/// - created_at -> number
|
||||||
|
/// - updated_by -> number
|
||||||
|
/// - updated_at -> number
|
||||||
|
/// - name -> text
|
||||||
|
/// - exchange_date -> number
|
||||||
|
///
|
||||||
|
/// Table - gift_exchange_participants
|
||||||
|
/// Columns - id -> number
|
||||||
|
/// - created_by -> number
|
||||||
|
/// - created_at -> number
|
||||||
|
/// - updated_by -> number
|
||||||
|
/// - updated_at -> number
|
||||||
|
/// - exchange_id -> number (reference gift_exchange table)
|
||||||
|
/// - participant_id -> number (reference user table)
|
||||||
|
/// - gifter_id -> number (reference user table)
|
||||||
|
///
|
||||||
|
/// Pages - sge_list
|
||||||
|
/// - list of gift exchanges user is part of
|
||||||
|
/// - sge_exchange
|
||||||
|
/// - exchange details
|
||||||
|
/// - list of participants
|
||||||
|
/// - sge_edit
|
||||||
|
/// - create new exchange
|
||||||
|
/// - edit existing exchange
|
||||||
|
/// - sge_participant_edit
|
||||||
|
/// - add or remove participant to exchange
|
||||||
|
///
|
||||||
|
/// API - select gifters
|
||||||
|
|
||||||
|
struct HtmlTemplate<T>(T);
|
||||||
|
|
||||||
|
impl<T> IntoResponse for HtmlTemplate<T>
|
||||||
|
where
|
||||||
|
T: Template,
|
||||||
|
{
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
match self.0.render() {
|
||||||
|
Ok(html) => Html(html).into_response(),
|
||||||
|
Err(err) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to render template. Error: {}", err),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
struct GiftExchange {
|
||||||
|
id: i64,
|
||||||
|
created_at: i64,
|
||||||
|
created_by: i64,
|
||||||
|
updated_at: i64,
|
||||||
|
updated_by: i64,
|
||||||
|
name: String,
|
||||||
|
exchange_date: i64,
|
||||||
|
status: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "giftexchanges.html")]
|
||||||
|
struct GiftExchangesTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
user: AccountData,
|
||||||
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
|
giftexchanges: Vec<GiftExchange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn giftexchanges(
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Is the user logged in?
|
||||||
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
let giftexchanges = sqlx::query_as::<_, GiftExchange>("SELECT * FROM gift_exchange")
|
||||||
|
.fetch_all(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if is_authorized("/giftexchange", user_data, db_pool.clone()).await {
|
||||||
|
// Get user roles
|
||||||
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
|
let template = GiftExchangesTemplate {
|
||||||
|
logged_in,
|
||||||
|
user,
|
||||||
|
user_roles,
|
||||||
|
giftexchanges,
|
||||||
|
};
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
struct GiftExchangeParticipant {
|
||||||
|
id: i64,
|
||||||
|
created_at: i64,
|
||||||
|
created_by: i64,
|
||||||
|
updated_at: i64,
|
||||||
|
updated_by: i64,
|
||||||
|
exchange_id: i64,
|
||||||
|
participant_id: i64,
|
||||||
|
gifter_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "giftexchange.html")]
|
||||||
|
struct GiftExchangeTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
user: AccountData,
|
||||||
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
|
giftexchange: GiftExchange,
|
||||||
|
participants: Vec<PersonData>,
|
||||||
|
non_participants: Vec<PersonData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn giftexchange(
|
||||||
|
Path(exchange_id): Path<i64>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Is the user logged in?
|
||||||
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
if is_authorized("/giftexchange", user_data, db_pool.clone()).await {
|
||||||
|
// Get user roles
|
||||||
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
|
// Get gift exchange
|
||||||
|
let giftexchange = match sqlx::query_as(
|
||||||
|
"SELECT * FROM gift_exchange WHERE id = ?")
|
||||||
|
.bind(exchange_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(giftexchange) => giftexchange,
|
||||||
|
Err(_) => GiftExchange::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get participants
|
||||||
|
let participants = sqlx::query_as::<_, PersonData>(
|
||||||
|
"select * from users where users.id in (select participant_id from gift_exchange_participants where exchange_id = $1)",
|
||||||
|
)
|
||||||
|
.bind(exchange_id)
|
||||||
|
.fetch_all(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Get non participants
|
||||||
|
let non_participants = sqlx::query_as::<_, PersonData>(
|
||||||
|
"select * from users where users.id not in (select participant_id from gift_exchange_participants where exchange_id = $1)",
|
||||||
|
)
|
||||||
|
.bind(exchange_id)
|
||||||
|
.fetch_all(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let template = GiftExchangeTemplate {
|
||||||
|
logged_in,
|
||||||
|
user,
|
||||||
|
user_roles,
|
||||||
|
giftexchange,
|
||||||
|
participants,
|
||||||
|
non_participants,
|
||||||
|
};
|
||||||
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ExchangeForm {
|
||||||
|
name: String,
|
||||||
|
exchange_date: String,
|
||||||
|
non_participants: Vec<uuid::Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn giftexchange_save(
|
||||||
|
State(_db_pool): State<PgPool>,
|
||||||
|
request: Request<Body>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let content_type_header = request.headers().get(CONTENT_TYPE);
|
||||||
|
let _content_type = content_type_header.and_then(|value| value.to_str().ok());
|
||||||
|
|
||||||
|
/* if let Some(content_type) = content_type {
|
||||||
|
if content_type.starts_with("application/json") {
|
||||||
|
let payload = request
|
||||||
|
.extract()
|
||||||
|
.await
|
||||||
|
.map_err(IntoResponse::into_response);
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_type.starts_with("application/x-www-form-urlencoded") {
|
||||||
|
let payload = request
|
||||||
|
.extract()
|
||||||
|
.await
|
||||||
|
.map_err(IntoResponse::into_response);
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
let (req_parts, map_request_body) = request.into_parts();
|
||||||
|
let bytes = match body::to_bytes(map_request_body, usize::MAX).await {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(err) => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("failed to read request body: {}", err),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Saving gift exchange: {:?}", req_parts);
|
||||||
|
println!("Saving gift exchange: {:?} ", bytes);
|
||||||
|
Ok(Redirect::to("/").into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct Payload {
|
||||||
|
foo: String,
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,37 @@
|
||||||
use askama_axum::IntoResponse;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::Redirect,
|
response::{IntoResponse, Redirect},
|
||||||
Extension,
|
Extension,
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
|
||||||
|
|
||||||
///User related structs and functions
|
///User related structs and functions
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, SqlitePool};
|
use sqlx::{FromRow, PgPool};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::middlewares::is_authorized;
|
use crate::middlewares::is_authorized;
|
||||||
|
|
||||||
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct UserData {
|
pub struct PersonData {
|
||||||
pub id: i64,
|
pub id: uuid::Uuid,
|
||||||
pub created_at: i64,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
pub created_by: i64,
|
pub created_by: uuid::Uuid,
|
||||||
pub updated_at: i64,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
pub updated_by: i64,
|
pub updated_by: uuid::Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub name: String,
|
||||||
|
pub family_name: String,
|
||||||
|
pub given_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct AccountData {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
pub created_by: uuid::Uuid,
|
||||||
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
|
pub updated_by: uuid::Uuid,
|
||||||
|
pub person_id: uuid::Uuid,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub family_name: String,
|
pub family_name: String,
|
||||||
|
|
@ -27,58 +40,58 @@ pub struct UserData {
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct RoleData {
|
pub struct RoleData {
|
||||||
pub id: i64,
|
pub id: uuid::Uuid,
|
||||||
pub created_at: i64,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
pub created_by: i64,
|
pub created_by: uuid::Uuid,
|
||||||
pub updated_at: i64,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
pub updated_by: i64,
|
pub updated_by: uuid::Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
|
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct UserRoles {
|
pub struct UserRoles {
|
||||||
pub id: i64,
|
pub id: uuid::Uuid,
|
||||||
pub created_at: i64,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
pub created_by: i64,
|
pub created_by: uuid::Uuid,
|
||||||
pub updated_at: i64,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
pub updated_by: i64,
|
pub updated_by: uuid::Uuid,
|
||||||
pub user_id: i64,
|
pub user_id: uuid::Uuid,
|
||||||
pub role_id: i64,
|
pub role_id: uuid::Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct UserRolesDisplay {
|
pub struct UserRolesDisplay {
|
||||||
pub id: i64,
|
pub id: uuid::Uuid,
|
||||||
pub created_at: i64,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
pub created_by: i64,
|
pub created_by: uuid::Uuid,
|
||||||
pub updated_at: i64,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
pub updated_by: i64,
|
pub updated_by: uuid::Uuid,
|
||||||
pub user_id: i64,
|
pub user_id: uuid::Uuid,
|
||||||
pub user_name: String,
|
pub user_name: String,
|
||||||
pub role_id: i64,
|
pub role_id: uuid::Uuid,
|
||||||
pub role_name: String,
|
pub role_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct UserWishlistItem {
|
pub struct UserWishlistItem {
|
||||||
pub id: i64,
|
pub id: uuid::Uuid,
|
||||||
pub created_at: i64,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
pub created_by: i64,
|
pub created_by: uuid::Uuid,
|
||||||
pub updated_at: i64,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
pub updated_by: i64,
|
pub updated_by: uuid::Uuid,
|
||||||
pub user_id: i64,
|
pub user_id: uuid::Uuid,
|
||||||
pub item: String,
|
pub item: String,
|
||||||
pub item_url: String,
|
pub item_url: String,
|
||||||
pub purchased_by: i64,
|
pub purchased_by: Option<uuid::Uuid>,
|
||||||
pub received_at: i64,
|
pub received_at: Option<chrono::NaiveDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
pub async fn get_user_roles(user_id: i64, db_pool: &SqlitePool) -> Vec<UserRoles> {
|
pub async fn get_user_roles(user_id: i64, db_pool: &PgPool) -> Vec<UserRoles> {
|
||||||
// Get user roles
|
// Get user roles
|
||||||
let user_roles = sqlx::query_as(
|
let user_roles = sqlx::query_as(
|
||||||
r#"SELECT id, created_at, created_by, updated_at, updated_by, user_id, role_id FROM user_roles WHERE user_id = ?"#
|
r#"SELECT id, created_at, created_by, updated_at, updated_by, user_id, role_id FROM user_roles WHERE user_id = $1"#
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_all(db_pool)
|
.fetch_all(db_pool)
|
||||||
|
|
@ -88,10 +101,13 @@ pub async fn get_user_roles(user_id: i64, db_pool: &SqlitePool) -> Vec<UserRoles
|
||||||
user_roles
|
user_roles
|
||||||
} */
|
} */
|
||||||
|
|
||||||
pub async fn get_user_roles_display(user_id: i64, db_pool: &SqlitePool) -> Vec<UserRolesDisplay> {
|
pub async fn get_user_roles_display(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
db_pool: &PgPool,
|
||||||
|
) -> Vec<UserRolesDisplay> {
|
||||||
// Get user roles
|
// Get user roles
|
||||||
let user_roles = sqlx::query_as(
|
let user_roles = sqlx::query_as(
|
||||||
r#"select ur.id, u.id as user_id, u.name as user_name, r.id as role_id, r.name as role_name, r.created_at, r.created_by, r.updated_at, r.updated_by from roles r join user_roles ur on ur.role_id = r.id join users u on u.id = ur.user_id WHERE ur.user_id = ?"#
|
r#"select ur.id, u.id as user_id, u.name as user_name, r.id as role_id, r.name as role_name, r.created_at, r.created_by, r.updated_at, r.updated_by from roles r join user_roles ur on ur.role_id = r.id join users u on u.id = ur.user_id WHERE ur.user_id = $1 order by r.name"#
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_all(db_pool)
|
.fetch_all(db_pool)
|
||||||
|
|
@ -101,10 +117,13 @@ pub async fn get_user_roles_display(user_id: i64, db_pool: &SqlitePool) -> Vec<U
|
||||||
user_roles
|
user_roles
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_other_roles_display(user_id: i64, db_pool: &SqlitePool) -> Vec<UserRolesDisplay> {
|
pub async fn get_other_roles_display(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
db_pool: &PgPool,
|
||||||
|
) -> Vec<UserRolesDisplay> {
|
||||||
// Get roles user does not have
|
// Get roles user does not have
|
||||||
let user_roles = sqlx::query_as(
|
let user_roles = sqlx::query_as(
|
||||||
r#"select 0 as id, r.created_at, r.created_by, r.updated_at, r.updated_by, ? as user_id, '' as user_name, r.id as role_id, r.name as role_name from roles r where r.id not in (select ur.role_id from user_roles ur where ur.user_id = ?)"#
|
r#"select r.id as id, r.created_at, r.created_by, r.updated_at, r.updated_by, $1 as user_id, '' as user_name, r.id as role_id, r.name as role_name from roles r where r.id not in (select ur.role_id from user_roles ur where ur.user_id = $2) order by r.name"#
|
||||||
)
|
)
|
||||||
.bind(user_id.clone())
|
.bind(user_id.clone())
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
|
@ -116,27 +135,28 @@ pub async fn get_other_roles_display(user_id: i64, db_pool: &SqlitePool) -> Vec<
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_user_role(
|
pub async fn add_user_role(
|
||||||
Path((user_id, role_id)): Path<(i64, i64)>,
|
Path((account_id, role_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
if is_authorized("/roles", user_data.clone(), db_pool.clone()).await {
|
if is_authorized("/roles", user_data.clone(), db_pool.clone()).await {
|
||||||
let now = Utc::now().timestamp();
|
sqlx::query("INSERT INTO user_roles (created_by, updated_by, user_id, role_id) VALUES ($1, $2, $3, $4)")
|
||||||
|
|
||||||
sqlx::query("INSERT INTO user_roles (created_at, created_by, updated_at, updated_by, user_id, role_id) VALUES (?, ?, ?, ?, ?, ?)")
|
|
||||||
.bind(now)// Created now
|
|
||||||
.bind(user_data.as_ref().unwrap().id)// Created by current user
|
.bind(user_data.as_ref().unwrap().id)// Created by current user
|
||||||
.bind(now) // Updated now
|
|
||||||
.bind(user_data.as_ref().unwrap().id) // Updated by current user
|
.bind(user_data.as_ref().unwrap().id) // Updated by current user
|
||||||
.bind(user_id)
|
.bind(account_id)
|
||||||
.bind(role_id)
|
.bind(role_id)
|
||||||
.execute(&db_pool)
|
.execute(&db_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// TODO - send email to user regarding role changes
|
// TODO - send email to user regarding role changes
|
||||||
|
let profile_id: Uuid = sqlx::query_scalar("SELECT person_id FROM users WHERE id = $1")
|
||||||
|
.bind(account_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let redirect_url = format!("/users/{user_id}");
|
let redirect_url = format!("/user/{profile_id}/{account_id}");
|
||||||
Redirect::to(&redirect_url).into_response()
|
Redirect::to(&redirect_url).into_response()
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
|
|
@ -144,29 +164,38 @@ pub async fn add_user_role(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_user_role(
|
pub async fn delete_user_role(
|
||||||
Path((user_id, user_role_id)): Path<(i64, i64)>,
|
Path((account_id, user_role_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
if is_authorized("/roles", user_data, db_pool.clone()).await {
|
if is_authorized("/roles", user_data, db_pool.clone()).await {
|
||||||
sqlx::query("DELETE FROM user_roles WHERE id = ?")
|
sqlx::query("DELETE FROM user_roles WHERE id = $1")
|
||||||
.bind(user_role_id)
|
.bind(user_role_id)
|
||||||
.execute(&db_pool)
|
.execute(&db_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let redirect_url = format!("/users/{user_id}");
|
let profile_id: Uuid = sqlx::query_scalar("SELECT person_id FROM users WHERE id = $1")
|
||||||
|
.bind(account_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let redirect_url = format!("/user/{profile_id}/{account_id}");
|
||||||
Redirect::to(&redirect_url).into_response()
|
Redirect::to(&redirect_url).into_response()
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_wishlist_item_by_id(item_id: i64, db_pool: &SqlitePool) -> UserWishlistItem {
|
pub async fn get_user_wishlist_item_by_id(
|
||||||
|
item_id: uuid::Uuid,
|
||||||
|
db_pool: &PgPool,
|
||||||
|
) -> UserWishlistItem {
|
||||||
// Get wish list items for the user
|
// Get wish list items for the user
|
||||||
let user_wishlist_item = sqlx::query_as(
|
let user_wishlist_item = sqlx::query_as(
|
||||||
r#"select id, created_at, created_by, updated_at, updated_by, user_id, item, item_url, purchased_by, received_at
|
r#"select id, created_at, created_by, updated_at, updated_by, user_id, item, item_url, purchased_by, received_at
|
||||||
from wishlist_items where id = ?"#
|
from wishlist_items where id = $1"#
|
||||||
)
|
)
|
||||||
.bind(item_id)
|
.bind(item_id)
|
||||||
.fetch_one(db_pool)
|
.fetch_one(db_pool)
|
||||||
|
|
@ -176,11 +205,14 @@ pub async fn get_user_wishlist_item_by_id(item_id: i64, db_pool: &SqlitePool) ->
|
||||||
user_wishlist_item
|
user_wishlist_item
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_wishlist_items(user_id: i64, db_pool: &SqlitePool) -> Vec<UserWishlistItem> {
|
pub async fn get_user_wishlist_items(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
db_pool: &PgPool,
|
||||||
|
) -> Vec<UserWishlistItem> {
|
||||||
// Get wish list items for the user
|
// Get wish list items for the user
|
||||||
let user_wishlist_items = sqlx::query_as(
|
let user_wishlist_items = sqlx::query_as(
|
||||||
r#"select id, created_at, created_by, updated_at, updated_by, user_id, item, item_url, purchased_by, received_at
|
r#"select id, created_at, created_by, updated_at, updated_by, user_id, item, item_url, purchased_by, received_at
|
||||||
from wishlist_items where user_id = ?"#
|
from wishlist_items where user_id = $1"#
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_all(db_pool)
|
.fetch_all(db_pool)
|
||||||
|
|
@ -190,12 +222,32 @@ pub async fn get_user_wishlist_items(user_id: i64, db_pool: &SqlitePool) -> Vec<
|
||||||
user_wishlist_items
|
user_wishlist_items
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_useremails_by_role(role_name: String, db_pool: &SqlitePool) -> String {
|
pub async fn get_useremails_by_role(role_name: String, db_pool: &PgPool) -> String {
|
||||||
let useremails: String = sqlx::query_scalar(r#"select group_concat(u.email) as email from user_roles ur, roles r, users u where u.id = ur.user_id and r.id = ur.role_id and r.name = ?"#)
|
let useremails: String = sqlx::query_scalar(r#"select string_agg(u.email, ',') as email from user_roles ur, roles r, users u where u.id = ur.user_id and r.id = ur.role_id and r.name = $1"#)
|
||||||
.bind(role_name)
|
.bind(role_name)
|
||||||
.fetch_one(db_pool)
|
.fetch_one(db_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
useremails
|
useremails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn user_account(
|
||||||
|
Path(account_id): Path<uuid::Uuid>,
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Is the user logged in?
|
||||||
|
let logged_in = user_data.is_some();
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
if is_authorized("/users", user_data.clone(), db_pool.clone()).await {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
use askama_axum::{IntoResponse, Response, Template};
|
use askama_axum::Template;
|
||||||
use axum::{extract::{Path, State}, response::Redirect, Extension, Form};
|
use axum::{
|
||||||
use axum_extra::response::Html;
|
extract::{Path, State},
|
||||||
|
response::{Html, IntoResponse, Redirect},
|
||||||
|
Extension, Form,
|
||||||
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::{SqlitePool, Row};
|
use sqlx::{Row, PgPool};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{middlewares::is_authorized, user::{get_user_roles_display, get_user_wishlist_item_by_id, get_user_wishlist_items, UserData, UserWishlistItem}};
|
use crate::{
|
||||||
|
middlewares::is_authorized,
|
||||||
|
user::{
|
||||||
|
get_user_roles_display, get_user_wishlist_item_by_id, get_user_wishlist_items, PersonData, AccountData,
|
||||||
|
UserWishlistItem,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
struct HtmlTemplate<T>(T);
|
struct HtmlTemplate<T>(T);
|
||||||
|
|
||||||
|
|
@ -14,7 +24,7 @@ impl<T> IntoResponse for HtmlTemplate<T>
|
||||||
where
|
where
|
||||||
T: Template,
|
T: Template,
|
||||||
{
|
{
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> http::Response<axum::body::Body> {
|
||||||
match self.0.render() {
|
match self.0.render() {
|
||||||
Ok(html) => Html(html).into_response(),
|
Ok(html) => Html(html).into_response(),
|
||||||
Err(err) => (
|
Err(err) => (
|
||||||
|
|
@ -30,37 +40,45 @@ where
|
||||||
#[template(path = "userwishlists.html")]
|
#[template(path = "userwishlists.html")]
|
||||||
struct WishListsTemplate {
|
struct WishListsTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
user_roles: Vec<crate::user::UserRolesDisplay>,
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
users: Vec<UserData>,
|
users: Vec<PersonData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wishlists(
|
pub async fn wishlists(
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
// Is the user logged in?
|
||||||
let logged_in = user_name.is_some();
|
let logged_in = user_data.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
|
||||||
|
|
||||||
let users = sqlx::query_as::<_, UserData>("SELECT * FROM users")
|
|
||||||
.fetch_all(&db_pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
let users = sqlx::query_as::<_, PersonData>(r#"SELECT p.* FROM people p
|
||||||
|
left join user_roles ur on ur.user_id = p.id
|
||||||
|
left join roles r on r.id = ur.role_id
|
||||||
|
where r.name = 'normal' order by p.name;"#)
|
||||||
|
.fetch_all(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
if is_authorized("/userwishlists", user_data, db_pool.clone()).await {
|
if is_authorized("/userwishlists", user_data, db_pool.clone()).await {
|
||||||
// Get user roles
|
// Get user roles
|
||||||
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
let template = WishListsTemplate {
|
let template = WishListsTemplate {
|
||||||
logged_in,
|
logged_in,
|
||||||
name,
|
user,
|
||||||
users,
|
users,
|
||||||
user_roles,
|
user_roles,
|
||||||
};
|
};
|
||||||
HtmlTemplate(template).into_response()
|
HtmlTemplate(template).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -70,53 +88,56 @@ pub async fn wishlists(
|
||||||
#[template(path = "userwishlist.html")]
|
#[template(path = "userwishlist.html")]
|
||||||
struct UserWishListTemplate {
|
struct UserWishListTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
my_wishlist: bool,
|
|
||||||
user: UserData,
|
|
||||||
user_roles: Vec<crate::user::UserRolesDisplay>,
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
user_wishlist_items: Vec<crate::user::UserWishlistItem>,
|
my_wishlist: bool,
|
||||||
|
person: PersonData,
|
||||||
|
person_wishlist_items: Vec<crate::user::UserWishlistItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_wishlist(
|
pub async fn user_wishlist(
|
||||||
Path(user_id): Path<i64>,
|
Path(user_id): Path<uuid::Uuid>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Extract the user's name from the user data.
|
// Is the user logged in?
|
||||||
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
|
||||||
let logged_in = user_data.is_some();
|
let logged_in = user_data.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
|
||||||
|
|
||||||
// Extract the user's id from the user data
|
if logged_in {
|
||||||
let user_userid = user_data.as_ref().map(|s| s.id.clone());
|
// Extract the user data.
|
||||||
let userid = user_userid.unwrap_or_default();
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
// Extract the user data.
|
// Extract the user data.
|
||||||
let user = sqlx::query_as!(UserData, "SELECT * FROM users WHERE id = ?", user_id)
|
let person = sqlx::query_as("SELECT * FROM people WHERE id = $1 or id = (select person_id from users where id = $1)")
|
||||||
.fetch_one(&db_pool)
|
.bind(user_id)
|
||||||
.await
|
.fetch_one(&db_pool)
|
||||||
.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
if is_authorized("/wishlist", user_data, db_pool.clone()).await {
|
if is_authorized("/wishlist", user_data, db_pool.clone()).await {
|
||||||
// Get user roles
|
// Get user roles
|
||||||
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
// Get user wishlist
|
// Get user wishlist
|
||||||
let user_wishlist_items = get_user_wishlist_items(user_id, &db_pool.clone()).await;
|
let person_wishlist_items = get_user_wishlist_items(user_id, &db_pool.clone()).await;
|
||||||
|
|
||||||
// Is viewed and viewing user the same (my wishlist)?
|
// Is viewed and viewing user the same (my wishlist)?
|
||||||
let my_wishlist = user_id == userid;
|
let my_wishlist = user_id == user.person_id;
|
||||||
|
|
||||||
// Create the wishlist template.
|
// Create the wishlist template.
|
||||||
let template = UserWishListTemplate {
|
let template = UserWishListTemplate {
|
||||||
logged_in,
|
logged_in,
|
||||||
name,
|
user,
|
||||||
my_wishlist,
|
user_roles,
|
||||||
user: user,
|
my_wishlist,
|
||||||
user_roles,
|
person,
|
||||||
user_wishlist_items,
|
person_wishlist_items,
|
||||||
};
|
};
|
||||||
return HtmlTemplate(template).into_response();
|
return HtmlTemplate(template).into_response();
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -126,56 +147,51 @@ pub async fn user_wishlist(
|
||||||
#[template(path = "userwishlistadd.html")]
|
#[template(path = "userwishlistadd.html")]
|
||||||
struct UserWishListAddTemplate {
|
struct UserWishListAddTemplate {
|
||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
name: String,
|
user: AccountData,
|
||||||
user: UserData,
|
|
||||||
user_roles: Vec<crate::user::UserRolesDisplay>,
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
user_wishlist_items: Vec<crate::user::UserWishlistItem>,
|
person: PersonData,
|
||||||
}
|
person_wishlist_items: Vec<crate::user::UserWishlistItem>,
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "userwishlistedit.html")]
|
|
||||||
struct UserWishListEditTemplate {
|
|
||||||
logged_in: bool,
|
|
||||||
name: String,
|
|
||||||
user: UserData,
|
|
||||||
user_roles: Vec<crate::user::UserRolesDisplay>,
|
|
||||||
user_wishlist_item: crate::user::UserWishlistItem,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_wishlist_add(
|
pub async fn user_wishlist_add(
|
||||||
Path(user_id): Path<i64>,
|
Path(user_id): Path<uuid::Uuid>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Extract the user's name from the user data.
|
// Is the user logged in?
|
||||||
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
|
||||||
let logged_in = user_data.is_some();
|
let logged_in = user_data.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
|
||||||
|
|
||||||
// Extract the user data.
|
if logged_in {
|
||||||
let user = sqlx::query_as!(UserData, "SELECT * FROM users WHERE id = ?", user_id)
|
// Extract the user data.
|
||||||
.fetch_one(&db_pool)
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
.await
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
// Extract the user data.
|
||||||
|
let person = sqlx::query_as("SELECT * FROM people WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
if is_authorized("/wishlist", user_data, db_pool.clone()).await {
|
if is_authorized("/wishlist", user_data, db_pool.clone()).await {
|
||||||
// Get user roles
|
// Get user roles
|
||||||
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
// Get user wishlist items
|
// Get user wishlist items
|
||||||
let user_wishlist_items = get_user_wishlist_items(user_id, &db_pool.clone()).await;
|
let person_wishlist_items = get_user_wishlist_items(user_id, &db_pool.clone()).await;
|
||||||
|
|
||||||
// Create the wishlist template.
|
// Create the wishlist template.
|
||||||
let template = UserWishListAddTemplate {
|
let template = UserWishListAddTemplate {
|
||||||
logged_in,
|
logged_in,
|
||||||
name,
|
user,
|
||||||
user: user,
|
user_roles,
|
||||||
user_roles,
|
person,
|
||||||
user_wishlist_items,
|
person_wishlist_items,
|
||||||
};
|
};
|
||||||
return HtmlTemplate(template).into_response();
|
return HtmlTemplate(template).into_response();
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -188,19 +204,15 @@ pub struct ItemForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_wishlist_add_item(
|
pub async fn user_wishlist_add_item(
|
||||||
Path(user_id): Path<i64>,
|
Path(user_id): Path<uuid::Uuid>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
Form(item_form): Form<ItemForm>
|
Form(item_form): Form<ItemForm>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
||||||
// Insert new item to database
|
// Insert new item to database
|
||||||
let now = Utc::now().timestamp();
|
sqlx::query("insert into wishlist_items (created_by, updated_by, user_id, item, item_url) values ($1, $2, $3, $4, $5)")
|
||||||
|
|
||||||
sqlx::query("insert into wishlist_items (created_at, created_by, updated_at, updated_by, user_id, item, item_url) values (?, ?, ?, ?, ?, ?, ?)")
|
|
||||||
.bind(now)// Created now
|
|
||||||
.bind(user_data.as_ref().unwrap().id)// Created by current user
|
.bind(user_data.as_ref().unwrap().id)// Created by current user
|
||||||
.bind(now) // Updated now
|
|
||||||
.bind(user_data.as_ref().unwrap().id) // Updated by current user
|
.bind(user_data.as_ref().unwrap().id) // Updated by current user
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(item_form.item)
|
.bind(item_form.item)
|
||||||
|
|
@ -209,57 +221,72 @@ pub async fn user_wishlist_add_item(
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let redirect_string = format!("/userwishlist/{user_id}");
|
// Redirect to wishlist
|
||||||
|
let person_id = user_data.as_ref().unwrap().person_id;
|
||||||
|
let redirect_string = format!("/userwishlist/{person_id}");
|
||||||
Redirect::to(&redirect_string).into_response()
|
Redirect::to(&redirect_string).into_response()
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "userwishlistedit.html")]
|
||||||
|
struct UserWishListEditTemplate {
|
||||||
|
logged_in: bool,
|
||||||
|
user: AccountData,
|
||||||
|
user_roles: Vec<crate::user::UserRolesDisplay>,
|
||||||
|
user_wishlist_item: crate::user::UserWishlistItem,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn user_wishlist_edit_item(
|
pub async fn user_wishlist_edit_item(
|
||||||
Path(item_id): Path<i64>,
|
Path(item_id): Path<uuid::Uuid>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Extract the user's name from the user data.
|
// Is the user logged in?
|
||||||
let user_name = user_data.as_ref().map(|s| s.name.clone());
|
|
||||||
let logged_in = user_data.is_some();
|
let logged_in = user_data.is_some();
|
||||||
let name = user_name.unwrap_or_default();
|
|
||||||
|
|
||||||
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
if logged_in {
|
||||||
|
// Extract the user data.
|
||||||
|
let user = user_data.as_ref().unwrap().clone();
|
||||||
|
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
||||||
// Get user roles
|
// Get user roles
|
||||||
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
|
||||||
|
|
||||||
// Get user wishlist items
|
// Get user wishlist items
|
||||||
let user_wishlist_item = get_user_wishlist_item_by_id(item_id, &db_pool.clone()).await;
|
let user_wishlist_item =
|
||||||
|
get_user_wishlist_item_by_id(item_id, &db_pool.clone()).await;
|
||||||
|
|
||||||
// Create the wishlist template.
|
// Create the wishlist template.
|
||||||
let template = UserWishListEditTemplate {
|
let template = UserWishListEditTemplate {
|
||||||
logged_in,
|
logged_in,
|
||||||
name,
|
user,
|
||||||
user: user_data.unwrap(),
|
user_roles,
|
||||||
user_roles,
|
user_wishlist_item,
|
||||||
user_wishlist_item,
|
};
|
||||||
};
|
return HtmlTemplate(template).into_response();
|
||||||
return HtmlTemplate(template).into_response();
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_wishlist_save_item(
|
pub async fn user_wishlist_save_item(
|
||||||
Path(item_id): Path<i64>,
|
Path(item_id): Path<uuid::Uuid>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>,
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
Form(item_form): Form<ItemForm>
|
Form(item_form): Form<ItemForm>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
||||||
// Insert new item to database
|
// Insert new item to database
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().naive_local();
|
||||||
|
|
||||||
sqlx::query("update wishlist_items set updated_at = ?, updated_by = ?, item = ?, item_url = ? where id = ?")
|
sqlx::query("update wishlist_items set updated_at = $1, updated_by = $2, item = $3, item_url = $4 where id = $5")
|
||||||
.bind(now) // Updated now
|
.bind(now) // Updated now
|
||||||
.bind(user_data.as_ref().unwrap().id) // Updated by current user
|
.bind(user_data.as_ref().unwrap().id) // Updated by current user
|
||||||
.bind(item_form.item)
|
.bind(item_form.item)
|
||||||
|
|
@ -269,8 +296,8 @@ pub async fn user_wishlist_save_item(
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let user_id = user_data.as_ref().unwrap().id;
|
let person_id = user_data.as_ref().unwrap().person_id;
|
||||||
let redirect_string = format!("/userwishlist/{user_id}");
|
let redirect_string = format!("/userwishlist/{person_id}");
|
||||||
Redirect::to(&redirect_string).into_response()
|
Redirect::to(&redirect_string).into_response()
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
|
|
@ -278,28 +305,28 @@ pub async fn user_wishlist_save_item(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_wishlist_bought_item(
|
pub async fn user_wishlist_bought_item(
|
||||||
Path(user_id): Path<i64>,
|
Path(item_id): Path<uuid::Uuid>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
||||||
// Update item to purchased
|
// Update item to purchased
|
||||||
sqlx::query("update wishlist_items set purchased_by = ? where id = ?")
|
sqlx::query("update wishlist_items set purchased_by = $1 where id = $2")
|
||||||
.bind(user_data.as_ref().unwrap().id)// Created by current user
|
.bind(user_data.as_ref().unwrap().id) // Created by current user
|
||||||
.bind(user_id)
|
.bind(item_id)
|
||||||
.execute(&db_pool)
|
.execute(&db_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Redirect to user wishlist
|
// Redirect to user wishlist
|
||||||
// Extract the user data.
|
// Extract the user data.
|
||||||
let row = sqlx::query( "SELECT user_id FROM wishlist_items WHERE id = ?")
|
let row = sqlx::query("SELECT user_id FROM wishlist_items WHERE id = $1")
|
||||||
.bind(user_id)
|
.bind(item_id)
|
||||||
.fetch_one(&db_pool)
|
.fetch_one(&db_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let userid = row.get::<i64, _>("user_id");
|
let userid = row.get::<uuid::Uuid, _>("user_id");
|
||||||
let redirect_string = format!("/userwishlist/{userid}");
|
let redirect_string = format!("/userwishlist/{userid}");
|
||||||
Redirect::to(&redirect_string).into_response()
|
Redirect::to(&redirect_string).into_response()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -308,24 +335,73 @@ pub async fn user_wishlist_bought_item(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_wishlist_received_item(
|
pub async fn user_wishlist_received_item(
|
||||||
Path(user_id): Path<i64>,
|
Path(user_id): Path<uuid::Uuid>,
|
||||||
State(db_pool): State<SqlitePool>,
|
State(db_pool): State<PgPool>,
|
||||||
Extension(user_data): Extension<Option<UserData>>
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
||||||
// Update item received time
|
// Update item received time
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().naive_local();
|
||||||
|
|
||||||
sqlx::query("update wishlist_items set received_at = ? where id = ?")
|
sqlx::query("update wishlist_items set received_at = $1 where id = $2")
|
||||||
.bind(now)// Received now
|
.bind(now) // Received now
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.execute(&db_pool)
|
.execute(&db_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Redirect to user wishlist
|
// Redirect to user wishlist
|
||||||
let userid = user_data.as_ref().unwrap().id;
|
let person_id = user_data.as_ref().unwrap().person_id;
|
||||||
let redirect_string = format!("/userwishlist/{userid}");
|
let redirect_string = format!("/userwishlist/{person_id}");
|
||||||
|
Redirect::to(&redirect_string).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_wishlist_delete_item(
|
||||||
|
Path(item_id): Path<uuid::Uuid>,
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
||||||
|
sqlx::query("delete from wishlist_items where id = $1")
|
||||||
|
.bind(item_id)
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Redirect to user wishlist
|
||||||
|
let person_id = user_data.as_ref().unwrap().person_id;
|
||||||
|
let redirect_string = format!("/userwishlist/{person_id}");
|
||||||
|
Redirect::to(&redirect_string).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_wishlist_returned_item(
|
||||||
|
Path(item_id): Path<uuid::Uuid>,
|
||||||
|
State(db_pool): State<PgPool>,
|
||||||
|
Extension(user_data): Extension<Option<AccountData>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if is_authorized("/wishlist", user_data.clone(), db_pool.clone()).await {
|
||||||
|
sqlx::query("update wishlist_items set purchased_by = null where id = $1")
|
||||||
|
.bind(item_id)
|
||||||
|
.execute(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Redirect to user wishlist
|
||||||
|
// Extract the user data.
|
||||||
|
let row = sqlx::query("SELECT user_id FROM wishlist_items WHERE id = $1")
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_one(&db_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let profileid = row.get::<Uuid, _>("user_id");
|
||||||
|
let redirect_string = format!("/userwishlist/{profileid}");
|
||||||
Redirect::to(&redirect_string).into_response()
|
Redirect::to(&redirect_string).into_response()
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,34 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid">
|
<div class="row align-items-stretch">
|
||||||
<div class="row align-items-stretch">
|
<!-- Left panel -->
|
||||||
<div id="menu" class="col-md-2 bg-light">
|
<div id="menu" class="col-md-2 bg-light">
|
||||||
<!-- internal menu -->
|
<h2>Menu</h2>
|
||||||
<h2>Menu</h2>
|
<ul>
|
||||||
<ul>
|
<li><a href="/dashboard">Dashboard</a></li>
|
||||||
<li><a href="/dashboard">Web links</a></li>
|
<li><a href="/calendar">Calendar</a></li>
|
||||||
<li><a href="/cottagecalendar">Cottage Calendar</a></li>
|
<li><a href="/wishlists">Wish lists</a></li>
|
||||||
<li><a href="/wishlists">Wish lists</a></li>
|
|
||||||
</ul>
|
|
||||||
{% for user_role in user_roles %}
|
{% for user_role in user_roles %}
|
||||||
{% if user_role.role_name == "admin" %}
|
{% if user_role.role_name == "admin" %}
|
||||||
<li><a href="/useradmin">User Administration</a></li>
|
<li>Administration</li>
|
||||||
{% endif %}
|
<ul>
|
||||||
|
<li><a href="/useradmin">User Administration</a></li>
|
||||||
|
<li><a href="/giftexchanges">Gift Exchanges</a></li>
|
||||||
|
<li><a href="/newevent">Add Event</a></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</ul>
|
||||||
<div class="col-8">
|
</div>
|
||||||
{% block center %}{% endblock center %}
|
|
||||||
</div>
|
<!-- Center panel -->
|
||||||
<div id="events" class="col-2 bg-light">
|
<div class="col-md-8">
|
||||||
<!-- events -->
|
{% block center %}{% endblock center %}
|
||||||
<h2>Events</h2>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Right panel -->
|
||||||
|
<div id="events" class="col-md-2 bg-light">
|
||||||
|
<h2>Events</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Jean-Marie Family</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.png">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="Chris Jean-Marie">
|
||||||
|
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
|
||||||
|
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
|
<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css'>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Montserrat", sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block links %}{% endblock links %}
|
||||||
|
</head>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.5/dist/bootstrap-table.min.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid" height="100vh">
|
||||||
|
<div class="row vh-100">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="row fixed-top sticky-top">
|
||||||
|
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">Jean-Marie Family</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/">Home</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/about">About</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/contactus">Contact Us</a>
|
||||||
|
</li>
|
||||||
|
{% if logged_in %}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/logout">Logout</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/profile">{{ user.name }}</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/login">Login</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<div class="row flex-1">
|
||||||
|
{% block content %}{% endblock content %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<div class="row fixed-bottom sticky-bottom">
|
||||||
|
<div class="container-fluid text-center bg-light">
|
||||||
|
<footer>
|
||||||
|
<p>© 2025 Jean-Marie family</p>
|
||||||
|
</footer>
|
||||||
|
</div><!-- /.container -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bootstrap JS Bundle with Popper -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
|
||||||
|
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||||
|
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<!-- Fingerprinting scripts -->
|
||||||
|
<script>
|
||||||
|
let currentFingerprint = '{{ fingerprint_hash }}';
|
||||||
|
|
||||||
|
// Generate fingerprint
|
||||||
|
async function generateFingerprint() {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.font = '14px Arial';
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
ctx.fillStyle = '#f60';
|
||||||
|
ctx.fillRect(125, 1, 62, 20);
|
||||||
|
ctx.fillStyle = '#069';
|
||||||
|
ctx.fillText('Device fingerprint', 2, 15);
|
||||||
|
const canvasFingerprint = canvas.toDataURL();
|
||||||
|
|
||||||
|
// Get available fonts (simplified)
|
||||||
|
const fonts = ['Arial', 'Helvetica', 'Times New Roman', 'Courier', 'Verdana', 'Georgia', 'Palatino', 'Garamond', 'Bookman', 'Tahoma'];
|
||||||
|
|
||||||
|
const fingerprintData = {
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
screen_width: screen.width,
|
||||||
|
screen_height: screen.height,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
language: navigator.language,
|
||||||
|
platform: navigator.platform,
|
||||||
|
canvas_fingerprint: canvasFingerprint,
|
||||||
|
fonts: fonts
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/fingerprint', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(fingerprintData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
currentFingerprint = result.fingerprint_hash;
|
||||||
|
|
||||||
|
// Reload page to show updated info
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
console.error('Failed to generate fingerprint:', response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating fingerprint:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% block scripts %}{% endblock scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.5/dist/bootstrap-table.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
|
||||||
|
{% block script %}{% endblock script %}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -8,10 +8,11 @@
|
||||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.png">
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.png">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="">
|
<meta name="description" content="">
|
||||||
<meta name="author" content="">
|
<meta name="author" content="Chris Jean-Marie">
|
||||||
|
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
|
||||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css'>
|
<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css'>
|
||||||
|
|
||||||
|
|
@ -20,13 +21,19 @@
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Montserrat", sans-serif;
|
font-family: "Montserrat", sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% block links %}{% endblock links %}
|
{% block links %}{% endblock links %}
|
||||||
</head>
|
</head>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.5/dist/bootstrap-table.min.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container-fluid">
|
<div class="container-fluid" height="100vh">
|
||||||
|
<div class="row vh-100">
|
||||||
|
|
||||||
<!-- HEADER -->
|
<!-- HEADER -->
|
||||||
<div class="row fixed-top sticky-top">
|
<div class="row fixed-top sticky-top">
|
||||||
|
|
@ -49,7 +56,7 @@
|
||||||
</li>
|
</li>
|
||||||
{% if logged_in %}
|
{% if logged_in %}
|
||||||
<li class="nav-item"><a class="nav-link" href="/logout">Logout</a></li>
|
<li class="nav-item"><a class="nav-link" href="/logout">Logout</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="/profile">{{ name }}</a></li>
|
<li class="nav-item"><a class="nav-link" href="/profile">{{ user.name }}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item"><a class="nav-link" href="/login">Login</a></li>
|
<li class="nav-item"><a class="nav-link" href="/login">Login</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -60,7 +67,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<div class="row flex-grow-1">
|
<div class="row flex-1">
|
||||||
{% block content %}{% endblock content %}
|
{% block content %}{% endblock content %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -68,18 +75,25 @@
|
||||||
<div class="row fixed-bottom sticky-bottom">
|
<div class="row fixed-bottom sticky-bottom">
|
||||||
<div class="container-fluid text-center bg-light">
|
<div class="container-fluid text-center bg-light">
|
||||||
<footer>
|
<footer>
|
||||||
<p>© 2024 Jean-Marie family</p>
|
<p>© 2025 Jean-Marie family</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div><!-- /.container -->
|
</div><!-- /.container -->
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bootstrap JS Bundle with Popper -->
|
<!-- Bootstrap JS Bundle with Popper -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
|
|
||||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||||
|
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
{% block scripts %}{% endblock scripts %}
|
{% block scripts %}{% endblock scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.5/dist/bootstrap-table.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
|
||||||
|
{% block script %}{% endblock script %}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
{% extends "authorized.html" %}
|
||||||
|
{% block links %}
|
||||||
|
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/core@4.2.0/main.min.css'>
|
||||||
|
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/daygrid@4.3.0/main.min.css'>
|
||||||
|
{% endblock links %}
|
||||||
|
{% block center %}
|
||||||
|
<div class="mh-100">
|
||||||
|
<div id="calendar" class="fc"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Event Modal -->
|
||||||
|
<div class="modal fade" id="eventDetailsModal" tabindex="-1" role="dialog" aria-labelledby="eventDetailsModalTitle"
|
||||||
|
aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<form id="eventDetailsModalForm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="eventDetailsModalTitle">Request dates</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eventTitle">Reservation for</label>
|
||||||
|
<input type="text" id="eventTitle" class="form-control" placeholder="Reservation for">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eventStart">Arriving</label>
|
||||||
|
<input type="date" class="form-control" id="eventStart" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eventEnd">Leaving</label>
|
||||||
|
<input type="date" class="form-control" id="eventEnd" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" id="eventDetailsModalClose" class="btn btn-secondary"
|
||||||
|
data-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Send Request</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Event Modal -->
|
||||||
|
<div class="modal fade" id="eventEditModal" tabindex="-1" role="dialog" aria-labelledby="eventEditModalTitle"
|
||||||
|
aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form id="eventForm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edit Event</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eventEditTitle">Reservation for</label>
|
||||||
|
<input type="text" id="eventEditTitle" class="form-control" placeholder="Event Title">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eventEditEnd">Arriving</label>
|
||||||
|
<input type="date" id="eventEditStart" class="form-control" placeholder="Start">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eventEditEnd">Leaving</label>
|
||||||
|
<input type="date" id="eventEditEnd" class="form-control" placeholder="End">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for user_role in user_roles %}
|
||||||
|
{% if user_role.role_name == "admin" %}
|
||||||
|
<h5>Actions</h5>
|
||||||
|
<ul>
|
||||||
|
<button type="button" id="eventApprove" onclick="updateEventState('Approved')">Approve</button>
|
||||||
|
<button type="button" id="eventReject" onclick="updateEventState('Rejected')">Reject</button>
|
||||||
|
<button type="button" id="eventConfirm" onclick="updateEventState('Confirmed')">Confirm</button>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" id="closeEventEdit"
|
||||||
|
data-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveEvent">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock center %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.17/index.global.min.js'></script>
|
||||||
|
<script src='https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js'></script>
|
||||||
|
<script src='https://cdn.jsdelivr.net/npm/uuid@8.3.2/dist/umd/uuidv4.min.js'></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var calendarEl = document.getElementById('calendar');
|
||||||
|
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||||
|
initialView: 'dayGridMonth',
|
||||||
|
themeSystem: 'bootstrap5',
|
||||||
|
selectable: true,
|
||||||
|
displayEventTime: true,
|
||||||
|
displayEventEnd: true,
|
||||||
|
slotDuration: { hours: 12 },
|
||||||
|
slotLabelInterval: { hours: 24 },
|
||||||
|
headerToolbar: {
|
||||||
|
left: 'prev,next today',
|
||||||
|
center: 'title',
|
||||||
|
right: 'dayGridMonth,timeGridWeek,timeGridDay,multiMonthYear'
|
||||||
|
},
|
||||||
|
events: '/calendar/getevents',
|
||||||
|
select: function (info) {
|
||||||
|
$('#eventStart').val(info.startStr);
|
||||||
|
$('#eventEnd').val(info.endStr);
|
||||||
|
$('#eventDetailsModal').modal('show');
|
||||||
|
},
|
||||||
|
eventClick: function (info) {
|
||||||
|
if (info.event.title != "In use") {
|
||||||
|
$('#eventEditTitle').val(info.event.title);
|
||||||
|
$('#eventEditStart').val(moment(info.event.start).format('YYYY-MM-DD'));
|
||||||
|
$('#eventEditEnd').val(moment(info.event.end).format('YYYY-MM-DD'));
|
||||||
|
$('#eventEditModal').modal('show');
|
||||||
|
// Store the event for later update
|
||||||
|
window.calEvent = info.event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
calendar.render();
|
||||||
|
|
||||||
|
document.getElementById('eventDetailsModalForm').addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var start = document.getElementById('eventStart').value;
|
||||||
|
var end = document.getElementById('eventEnd').value;
|
||||||
|
|
||||||
|
// Prepare the event data
|
||||||
|
var eventData = {
|
||||||
|
start: start,
|
||||||
|
end: end
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send data to the API
|
||||||
|
fetch('/calendar/newrequest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(eventData)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// Optionally, use the response to add the event to the calendar
|
||||||
|
$('#eventDetailsModal').modal('hide');
|
||||||
|
calendar.addEvent({
|
||||||
|
title: data.title,
|
||||||
|
start: data.start,
|
||||||
|
end: data.end,
|
||||||
|
allDay: data.allDay,
|
||||||
|
backgroundColor: data.backgroundColor
|
||||||
|
});
|
||||||
|
e.target.reset();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error creating event:', error);
|
||||||
|
alert('An error occurred while creating the event.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('eventDetailsModalClose').addEventListener('click', function () {
|
||||||
|
$('#eventDetailsModal').modal('hide');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#saveEvent').on('click', function () {
|
||||||
|
var updatedEvent = {
|
||||||
|
id: window.calEvent.id,
|
||||||
|
title: $('#eventEditTitle').val(),
|
||||||
|
start: $('#eventEditStart').val(),
|
||||||
|
end: $('#eventEditEnd').val()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save the updates to the record
|
||||||
|
fetch('/calendar/updaterequest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatedEvent)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// Optionally, use the response to add the event to the calendar
|
||||||
|
$('#eventDetailsModal').modal('hide');
|
||||||
|
// Update the original event object
|
||||||
|
if (window.calEvent.title != data.title) {
|
||||||
|
window.calEvent.setProp('title', data.title);
|
||||||
|
}
|
||||||
|
if (window.calEvent.start != data.start) {
|
||||||
|
window.calEvent.setStart(data.start);
|
||||||
|
}
|
||||||
|
if (window.calEvent.end != data.end) {
|
||||||
|
window.calEvent.setEnd(data.end);
|
||||||
|
}
|
||||||
|
window.calEvent.setProp('allDay', data.allDay);
|
||||||
|
window.calEvent.setProp('backgroundColor', data.backgroundColor);
|
||||||
|
|
||||||
|
e.target.reset();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error creating event:', error);
|
||||||
|
//alert('An error occurred while creating the event.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide the popup
|
||||||
|
$('#eventEditModal').modal('hide');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#closeEventEdit').on('click', function () {
|
||||||
|
$('#eventEditModal').modal('hide');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateEventState(state) {
|
||||||
|
var updatedEvent = {
|
||||||
|
id: window.calEvent.id,
|
||||||
|
eventType: 'Reservation',
|
||||||
|
state: state
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save the updates to the record
|
||||||
|
fetch('/calendar/updateeventstate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatedEvent)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// Update the original event object
|
||||||
|
window.calEvent.setProp('backgroundColor', data.backgroundColor);
|
||||||
|
|
||||||
|
e.target.reset();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error creating event:', error);
|
||||||
|
//alert('An error occurred while creating the event.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock scripts %}
|
||||||
|
|
@ -1,452 +0,0 @@
|
||||||
{% extends "authorized.html" %}
|
|
||||||
{% block links %}
|
|
||||||
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/core@4.2.0/main.min.css'>
|
|
||||||
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/daygrid@4.3.0/main.min.css'>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#calendar {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 40px auto;
|
|
||||||
background: #fff;
|
|
||||||
padding: 15px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-event {
|
|
||||||
border: 1px solid #eee !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-content {
|
|
||||||
padding: 3px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-content .fc-title {
|
|
||||||
display: block !important;
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-customButton-button {
|
|
||||||
font-size: 13px !important;
|
|
||||||
position: absolute;
|
|
||||||
top: 0px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group>label {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#delete-modal .modal-footer>.btn {
|
|
||||||
|
|
||||||
border-radius: 3px !important;
|
|
||||||
padding: 0px 8px !important;
|
|
||||||
font-size: 15px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.fc-scroller {
|
|
||||||
overflow-y: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1000;
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.3);
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .context-menu.show {
|
|
||||||
display: block;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.context-menu ul {
|
|
||||||
list-style-type: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu ul>li {
|
|
||||||
display: block;
|
|
||||||
;
|
|
||||||
padding: 5px 15px;
|
|
||||||
list-style-type: none;
|
|
||||||
color: #333;
|
|
||||||
display: block;
|
|
||||||
cursor: pointer;
|
|
||||||
margin: 0 auto;
|
|
||||||
transition: 0.10s;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu ul>li:hover {
|
|
||||||
color: #fff;
|
|
||||||
background-color: #007bff;
|
|
||||||
border-radius: 2px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.fa,
|
|
||||||
.fas {
|
|
||||||
font-size: 13px;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock links %}
|
|
||||||
{% block center %}
|
|
||||||
<h1>Cottage Calendar</h1>
|
|
||||||
<div id='calendar'></div>
|
|
||||||
|
|
||||||
<!-- Add modal -->
|
|
||||||
|
|
||||||
<div class="modal fade edit-form" id="form" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-dialog" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header border-bottom-0">
|
|
||||||
<h5 class="modal-title" id="modal-title">Add Event</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<form id="myForm">
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="alert alert-danger " role="alert" id="danger-alert" style="display: none;">
|
|
||||||
End date should be greater than start date.
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-title">Event name <span class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="event-title" placeholder="Enter event name"
|
|
||||||
required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="start-date">Start date <span class="text-danger">*</span></label>
|
|
||||||
<input type="date" class="form-control" id="start-date" placeholder="start-date" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="end-date">End date - <small class="text-muted">Optional</small></label>
|
|
||||||
<input type="date" class="form-control" id="end-date" placeholder="end-date">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-color">Color</label>
|
|
||||||
<input type="color" class="form-control" id="event-color" value="#3788d8">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-top-0 d-flex justify-content-center">
|
|
||||||
<button type="submit" class="btn btn-success" id="submit-button">Submit</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Modal -->
|
|
||||||
<div class="modal fade" id="delete-modal" tabindex="-1" role="dialog" aria-labelledby="delete-modal-title"
|
|
||||||
aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-dialog" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="delete-modal-title">Confirm Deletion</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body text-center" id="delete-modal-body">
|
|
||||||
Are you sure you want to delete the event?
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-0">
|
|
||||||
<button type="button" class="btn btn-secondary rounded-sm" data-dismiss="modal"
|
|
||||||
id="cancel-button">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-danger rounded-lg" id="delete-button">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock center %}
|
|
||||||
{% block scripts %}
|
|
||||||
<script src='https://cdn.jsdelivr.net/npm/@fullcalendar/core@4.2.0/main.min.js'></script>
|
|
||||||
<script src='https://cdn.jsdelivr.net/npm/@fullcalendar/daygrid@4.2.0/main.js'></script>
|
|
||||||
<script src='https://cdn.jsdelivr.net/npm/@fullcalendar/interaction@4.2.0/main.js'></script>
|
|
||||||
<script src='https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js'></script>
|
|
||||||
<script src='https://cdn.jsdelivr.net/npm/uuid@8.3.2/dist/umd/uuidv4.min.js'></script>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
const calendarEl = document.getElementById('calendar');
|
|
||||||
const myModal = new bootstrap.Modal(document.getElementById('form'));
|
|
||||||
const dangerAlert = document.getElementById('danger-alert');
|
|
||||||
const close = document.querySelector('.btn-close');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const myEvents = JSON.parse(localStorage.getItem('events')) || [
|
|
||||||
{
|
|
||||||
id: uuidv4(),
|
|
||||||
title: `Edit Me`,
|
|
||||||
start: '2023-04-11',
|
|
||||||
backgroundColor: 'red',
|
|
||||||
allDay: false,
|
|
||||||
editable: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: uuidv4(),
|
|
||||||
title: `Delete me`,
|
|
||||||
start: '2023-04-17',
|
|
||||||
end: '2023-04-21',
|
|
||||||
|
|
||||||
allDay: false,
|
|
||||||
editable: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
const calendar = new FullCalendar.Calendar(calendarEl, {
|
|
||||||
customButtons: {
|
|
||||||
customButton: {
|
|
||||||
text: 'Add Event',
|
|
||||||
click: function () {
|
|
||||||
myModal.show();
|
|
||||||
const modalTitle = document.getElementById('modal-title');
|
|
||||||
const submitButton = document.getElementById('submit-button');
|
|
||||||
modalTitle.innerHTML = 'Add Event'
|
|
||||||
submitButton.innerHTML = 'Add Event'
|
|
||||||
submitButton.classList.remove('btn-primary');
|
|
||||||
submitButton.classList.add('btn-success');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
close.addEventListener('click', () => {
|
|
||||||
myModal.hide()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
center: 'customButton', // add your custom button here
|
|
||||||
right: 'today, prev,next '
|
|
||||||
},
|
|
||||||
plugins: ['dayGrid', 'interaction'],
|
|
||||||
allDay: false,
|
|
||||||
editable: true,
|
|
||||||
selectable: true,
|
|
||||||
unselectAuto: false,
|
|
||||||
displayEventTime: false,
|
|
||||||
events: myEvents,
|
|
||||||
eventRender: function (info) {
|
|
||||||
info.el.addEventListener('contextmenu', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
let existingMenu = document.querySelector('.context-menu');
|
|
||||||
existingMenu && existingMenu.remove();
|
|
||||||
let menu = document.createElement('div');
|
|
||||||
menu.className = 'context-menu';
|
|
||||||
menu.innerHTML = `<ul>
|
|
||||||
<li><i class="fas fa-edit"></i>Edit</li>
|
|
||||||
<li><i class="fas fa-trash-alt"></i>Delete</li>
|
|
||||||
</ul>`;
|
|
||||||
|
|
||||||
const eventIndex = myEvents.findIndex(event => event.id === info.event.id);
|
|
||||||
|
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
|
||||||
menu.style.top = e.pageY + 'px';
|
|
||||||
menu.style.left = e.pageX + 'px';
|
|
||||||
|
|
||||||
// Edit context menu
|
|
||||||
|
|
||||||
menu.querySelector('li:first-child').addEventListener('click', function () {
|
|
||||||
menu.remove();
|
|
||||||
|
|
||||||
const editModal = new bootstrap.Modal(document.getElementById('form'));
|
|
||||||
const modalTitle = document.getElementById('modal-title');
|
|
||||||
const titleInput = document.getElementById('event-title');
|
|
||||||
const startDateInput = document.getElementById('start-date');
|
|
||||||
const endDateInput = document.getElementById('end-date');
|
|
||||||
const colorInput = document.getElementById('event-color');
|
|
||||||
const submitButton = document.getElementById('submit-button');
|
|
||||||
const cancelButton = document.getElementById('cancel-button');
|
|
||||||
modalTitle.innerHTML = 'Edit Event';
|
|
||||||
titleInput.value = info.event.title;
|
|
||||||
startDateInput.value = moment(info.event.start).format('YYYY-MM-DD');
|
|
||||||
endDateInput.value = moment(info.event.end, 'YYYY-MM-DD').subtract(1, 'day').format('YYYY-MM-DD');
|
|
||||||
colorInput.value = info.event.backgroundColor;
|
|
||||||
submitButton.innerHTML = 'Save Changes';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
editModal.show();
|
|
||||||
|
|
||||||
submitButton.classList.remove('btn-success')
|
|
||||||
submitButton.classList.add('btn-primary')
|
|
||||||
|
|
||||||
// Edit button
|
|
||||||
|
|
||||||
submitButton.addEventListener('click', function () {
|
|
||||||
const updatedEvents = {
|
|
||||||
id: info.event.id,
|
|
||||||
title: titleInput.value,
|
|
||||||
start: startDateInput.value,
|
|
||||||
end: moment(endDateInput.value, 'YYYY-MM-DD').add(1, 'day').format('YYYY-MM-DD'),
|
|
||||||
backgroundColor: colorInput.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updatedEvents.end <= updatedEvents.start) { // add if statement to check end date
|
|
||||||
dangerAlert.style.display = 'block';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventIndex = myEvents.findIndex(event => event.id === updatedEvents.id);
|
|
||||||
myEvents.splice(eventIndex, 1, updatedEvents);
|
|
||||||
|
|
||||||
localStorage.setItem('events', JSON.stringify(myEvents));
|
|
||||||
|
|
||||||
// Update the event in the calendar
|
|
||||||
const calendarEvent = calendar.getEventById(info.event.id);
|
|
||||||
calendarEvent.setProp('title', updatedEvents.title);
|
|
||||||
calendarEvent.setStart(updatedEvents.start);
|
|
||||||
calendarEvent.setEnd(updatedEvents.end);
|
|
||||||
calendarEvent.setProp('backgroundColor', updatedEvents.backgroundColor);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
editModal.hide();
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete menu
|
|
||||||
menu.querySelector('li:last-child').addEventListener('click', function () {
|
|
||||||
const deleteModal = new bootstrap.Modal(document.getElementById('delete-modal'));
|
|
||||||
const modalBody = document.getElementById('delete-modal-body');
|
|
||||||
const cancelModal = document.getElementById('cancel-button');
|
|
||||||
modalBody.innerHTML = `Are you sure you want to delete <b>"${info.event.title}"</b>`
|
|
||||||
deleteModal.show();
|
|
||||||
|
|
||||||
const deleteButton = document.getElementById('delete-button');
|
|
||||||
deleteButton.addEventListener('click', function () {
|
|
||||||
myEvents.splice(eventIndex, 1);
|
|
||||||
localStorage.setItem('events', JSON.stringify(myEvents));
|
|
||||||
calendar.getEventById(info.event.id).remove();
|
|
||||||
deleteModal.hide();
|
|
||||||
menu.remove();
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
cancelModal.addEventListener('click', function () {
|
|
||||||
deleteModal.hide();
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
|
||||||
document.addEventListener('click', function () {
|
|
||||||
menu.remove();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
eventDrop: function (info) {
|
|
||||||
let myEvents = JSON.parse(localStorage.getItem('events')) || [];
|
|
||||||
const eventIndex = myEvents.findIndex(event => event.id === info.event.id);
|
|
||||||
const updatedEvent = {
|
|
||||||
...myEvents[eventIndex],
|
|
||||||
id: info.event.id,
|
|
||||||
title: info.event.title,
|
|
||||||
start: moment(info.event.start).format('YYYY-MM-DD'),
|
|
||||||
end: moment(info.event.end).format('YYYY-MM-DD'),
|
|
||||||
backgroundColor: info.event.backgroundColor
|
|
||||||
};
|
|
||||||
myEvents.splice(eventIndex, 1, updatedEvent); // Replace old event data with updated event data
|
|
||||||
localStorage.setItem('events', JSON.stringify(myEvents));
|
|
||||||
console.log(updatedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
calendar.on('select', function (info) {
|
|
||||||
|
|
||||||
const startDateInput = document.getElementById('start-date');
|
|
||||||
const endDateInput = document.getElementById('end-date');
|
|
||||||
startDateInput.value = info.startStr;
|
|
||||||
const endDate = moment(info.endStr, 'YYYY-MM-DD').subtract(1, 'day').format('YYYY-MM-DD');
|
|
||||||
endDateInput.value = endDate;
|
|
||||||
if (startDateInput.value === endDate) {
|
|
||||||
endDateInput.value = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
calendar.render();
|
|
||||||
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
|
|
||||||
form.addEventListener('submit', function (event) {
|
|
||||||
event.preventDefault(); // prevent default form submission
|
|
||||||
|
|
||||||
// retrieve the form input values
|
|
||||||
const title = document.querySelector('#event-title').value;
|
|
||||||
const startDate = document.querySelector('#start-date').value;
|
|
||||||
const endDate = document.querySelector('#end-date').value;
|
|
||||||
const color = document.querySelector('#event-color').value;
|
|
||||||
const endDateFormatted = moment(endDate, 'YYYY-MM-DD').add(1, 'day').format('YYYY-MM-DD');
|
|
||||||
const eventId = uuidv4();
|
|
||||||
|
|
||||||
console.log(eventId);
|
|
||||||
|
|
||||||
if (endDateFormatted <= startDate) { // add if statement to check end date
|
|
||||||
dangerAlert.style.display = 'block';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEvent = {
|
|
||||||
id: eventId,
|
|
||||||
title: title,
|
|
||||||
start: startDate,
|
|
||||||
end: endDateFormatted,
|
|
||||||
allDay: false,
|
|
||||||
backgroundColor: color
|
|
||||||
};
|
|
||||||
|
|
||||||
// add the new event to the myEvents array
|
|
||||||
myEvents.push(newEvent);
|
|
||||||
|
|
||||||
// render the new event on the calendar
|
|
||||||
calendar.addEvent(newEvent);
|
|
||||||
|
|
||||||
// save events to local storage
|
|
||||||
localStorage.setItem('events', JSON.stringify(myEvents));
|
|
||||||
|
|
||||||
myModal.hide();
|
|
||||||
form.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
myModal._element.addEventListener('hide.bs.modal', function () {
|
|
||||||
dangerAlert.style.display = 'none';
|
|
||||||
form.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock scripts %}
|
|
||||||
|
|
@ -1,22 +1,55 @@
|
||||||
{% extends "authorized.html" %}
|
{% extends "authorized.html" %}
|
||||||
{% block center %}
|
{% block center %}
|
||||||
<p>This will be the private information area for the extended Jean-Marie family.</p>
|
<div class="row align-items-stretch">
|
||||||
<div>
|
<div class="col-md-6">
|
||||||
<h2>Web links</h2>
|
<div class="card">
|
||||||
<h3>TLC Creations</h3>
|
<div class="card-body">
|
||||||
<ul>
|
<h5 class="card-title">Points of Interest</h5>
|
||||||
<li><a href="https://www.tlccreations.ca" target="_blank" rel="noopener noreferrer">TLC Creations</a></li>
|
<div class="col-md-6">
|
||||||
</ul>
|
<a href="https://www.tlccreations.ca" target="_blank" rel="noopener noreferrer"><img
|
||||||
<h3>Fonts</h3>
|
title="TLC Creations" src="https://www.tlccreations.ca/assets/images/banner.png"
|
||||||
<ul>
|
class="img-fluid" alt="TLC Creations"></a>
|
||||||
<li><a href="https://fonts.google.com" target="_blank" rel="noopener noreferrer">Google fonts</a></li>
|
</div>
|
||||||
<li><a href="https://www.fontspace.com" target="_blank" rel="noopener noreferrer">Font Space</a></li>
|
<div class="col-md-6">
|
||||||
</ul>
|
<a href="https://www.seguin.ca" target="_blank" rel="noopener noreferrer"><img
|
||||||
<h3>Family tree</h3>
|
title="Seguin Township" src="https://www.seguin.ca/en/images/structure/badge.svg"
|
||||||
<ul>
|
class="img-fluid" alt="Seguin Township"></a>
|
||||||
<li><a href="https://www.ancestry.ca" target="_blank" rel="noopener noreferrer">Ancestry</a></li>
|
</div>
|
||||||
<li><a href="https://www.geni.com" target="_blank" rel="noopener noreferrer">Geni</a></li>
|
</div>
|
||||||
<li><a href="http://www.tracingroots.ca/" target="_blank" rel="noopener noreferrer">Tracing Roots - Forth Family Tree</a></li>
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Seguin Fire Rating</h5>
|
||||||
|
<a href="https://www.seguin.ca/en/explore-play/firerating.aspx" target="_blank"
|
||||||
|
rel="noopener noreferrer"><img title="Fire Rating: MODERATE"
|
||||||
|
src="https://www.seguin.ca/en/resources/firemodseguin.jpg" class="img-fluid"
|
||||||
|
alt="Fire Rating: MODERATE"></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row align-items-stretch">
|
||||||
|
<div>
|
||||||
|
<h2>Web links</h2>
|
||||||
|
<h3>Fonts</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://fonts.google.com" target="_blank" rel="noopener noreferrer">Google fonts</a></li>
|
||||||
|
<li><a href="https://www.fontspace.com" target="_blank" rel="noopener noreferrer">Font Space</a></li>
|
||||||
|
</ul>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Family tree</h5>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://www.ancestry.ca" target="_blank" rel="noopener noreferrer">Ancestry</a></li>
|
||||||
|
<li><a href="https://www.geni.com" target="_blank" rel="noopener noreferrer">Geni</a></li>
|
||||||
|
<li><a href="http://www.tracingroots.ca/" target="_blank" rel="noopener noreferrer">Tracing Roots -
|
||||||
|
Forth Family
|
||||||
|
Tree</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock center %}
|
{% endblock center %}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
User name: {{ user.name }}</p>
|
||||||
|
Has access: {{ access}}</p>
|
||||||
|
Database connection: {{ connected }}</p>
|
||||||
|
Client fingerprint: {{ identity.fingerprint_hash }}</p>
|
||||||
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
{{ device_status }}</p>
|
||||||
|
{{ fingerprint_hash }}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Device Administration{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Here are the devices</h1>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
{% extends "authorized.html" %}
|
||||||
|
{% block title %}Gift Exchange{% endblock %}
|
||||||
|
{% block center %}
|
||||||
|
<form action="/giftexchange/{{ giftexchange.id }}" method="post">
|
||||||
|
<div class="row">
|
||||||
|
<div class="btn-toolbar" role="toolbar">
|
||||||
|
<a role="button" class="btn btn-primary" href="/giftexchange/{{ giftexchange.id }}">Save</a>
|
||||||
|
<button id="update" type="submit" class="btn btn-danger">Update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Exchange Name:</label>
|
||||||
|
<input id="name" class="form-control" name="name" type="text" value="{{ giftexchange.name }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="exchange_date">Exchange Date:</label>
|
||||||
|
<input id="exchange_date" name="exchange_date" type="date" value="{{ giftexchange.exchange_date }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row align-items-center text-center">
|
||||||
|
<div class="col">
|
||||||
|
<table id="non_participants" data-toggle="table" data-click-to-select="true"
|
||||||
|
data-height="400">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-field="state" data-checkbox="true"></th>
|
||||||
|
<th data-sortable="true" data-field="name" scope="col">Available</th>
|
||||||
|
<th data-hidden="true" data-field="id" scope="col">ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="np_tbody">
|
||||||
|
{% for participant in non_participants %}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{{ participant.name }}</td>
|
||||||
|
<td>{{ participant.id }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="btn-group-vertical text-center" role="group">
|
||||||
|
<button id="add" type="button" class="btn btn-primary">Add</button>
|
||||||
|
<button id="remove" type="button" class="btn btn-primary">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<table id="participants" data-toggle="table" data-click-to-select="true" data-height="400">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-field="state" data-checkbox="true"></th>
|
||||||
|
<th data-sortable="true" data-field="name" scope="col">Participating</th>
|
||||||
|
<th data-hidden="true" data-field="id" scope="col">ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="p_tbody">
|
||||||
|
{% for participant in participants %}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{{ participant.name }}</td>
|
||||||
|
<td>{{ participant.id }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock center %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(() => {
|
||||||
|
var npRows = [];
|
||||||
|
var pRows = [];
|
||||||
|
|
||||||
|
$('#non_participants').bootstrapTable('hideColumn', 'id');
|
||||||
|
$('#participants').bootstrapTable('hideColumn', 'id');
|
||||||
|
|
||||||
|
$('#non_participants').on('check.bs.table', function (e, row) {
|
||||||
|
npRows.push({ name: row.name, id: row.id });
|
||||||
|
console.log(npRows);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#non_participants').on('uncheck.bs.table', function (e, row) {
|
||||||
|
$.each(npRows, function (index, value) {
|
||||||
|
if (value.id === row.id) {
|
||||||
|
npRows.splice(index, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(npRows);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#participants').on('check.bs.table', function (e, row) {
|
||||||
|
pRows.push({ name: row.name, id: row.id });
|
||||||
|
console.log(pRows);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#participants').on('uncheck.bs.table', function (e, row) {
|
||||||
|
$.each(pRows, function (index, value) {
|
||||||
|
if (value.id === row.id) {
|
||||||
|
pRows.splice(index, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(pRows);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#add").click(function () {
|
||||||
|
$.each(npRows, function (index, value) {
|
||||||
|
$('#non_participants').bootstrapTable('remove', {
|
||||||
|
field: 'id',
|
||||||
|
values: [value.id]
|
||||||
|
});
|
||||||
|
$('#participants').bootstrapTable('insertRow', {
|
||||||
|
index: 0,
|
||||||
|
row: {
|
||||||
|
name: value.name,
|
||||||
|
id: value.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
npRows = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#remove").click(function () {
|
||||||
|
$.each(pRows, function (index, value) {
|
||||||
|
$('#participants').bootstrapTable('remove', {
|
||||||
|
field: 'id',
|
||||||
|
values: [value.id]
|
||||||
|
});
|
||||||
|
$('#non_participants').bootstrapTable('insertRow', {
|
||||||
|
index: 0,
|
||||||
|
row: {
|
||||||
|
name: value.name,
|
||||||
|
id: value.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
pRows = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#update").click(function () {
|
||||||
|
$.each(pRows, function (index, value) {
|
||||||
|
$('#participants').bootstrapTable('insertRow', {
|
||||||
|
index: 0,
|
||||||
|
row: {
|
||||||
|
name: value.name,
|
||||||
|
id: value.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
pRows = [];
|
||||||
|
});
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock script %}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
{% extends "authorized.html" %}
|
||||||
|
{% block title %}Gift Exchanges{% endblock %}
|
||||||
|
{% block center %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="btn-toolbar" role="toolbar">
|
||||||
|
<a role="button" class="btn btn-primary" href="/giftexchange/0">Add</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<table data-toggle="table" class="table table-striped table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sortable="true" data-field="name" scope="col">Name</th>
|
||||||
|
<th data-sortable="true" data-field="exchange_date" scope="col">Date</th>
|
||||||
|
<th data-field="status" scope="col">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for giftexchange in giftexchanges %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/giftexchange/{{ giftexchange.id }}">{{ giftexchange.name }}</a></td>
|
||||||
|
<td>{{ giftexchange.exchange_date }}</td>
|
||||||
|
<td>{{ giftexchange.status }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock center %}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
{% extends "authorized.html" %}
|
||||||
|
{% block center %}
|
||||||
|
<h1>Create Event</h1>
|
||||||
|
|
||||||
|
<form method="post" action="/createevent">
|
||||||
|
<div>
|
||||||
|
<label for="title">Title:</label>
|
||||||
|
<input type="text" id="title" name="title" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="description">Description:</label>
|
||||||
|
<textarea id="description" name="description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="state">State:</label>
|
||||||
|
<select id="state" name="state" required>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="calendar_id">Calendar ID:</label>
|
||||||
|
<select id="calendar_id" name="calendar_id" required>
|
||||||
|
{% for cal in calendars %}
|
||||||
|
<option value="{{ cal.id }}">{{ cal.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="event_type_id">Event Type ID:</label>
|
||||||
|
<input type="text" id="event_type_id" name="event_type_id" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="start_time">Start Date:</label>
|
||||||
|
<input type="date" id="start_time" name="start_time" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="end_time">End Date:</label>
|
||||||
|
<input type="date" id="end_time" name="end_time" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Create Event</button>
|
||||||
|
</form>
|
||||||
|
{% endblock center %}
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Device Fingerprinting Demo{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h1 class="h3 mb-0">Device Fingerprinting Demo</h1>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="device-status" class="alert alert-info">
|
||||||
|
{{ device_status }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fingerprint-info" class="mt-4">
|
||||||
|
<h5>Device Information:</h5>
|
||||||
|
<p><strong>Fingerprint:</strong> <span id="fingerprint-hash">{{ fingerprint_hash }}</span></p>
|
||||||
|
|
||||||
|
{% if device %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<p><strong>Device Name:</strong> {{ device.device_name }}</p>
|
||||||
|
<p><strong>First Seen:</strong> {{ device.first_seen }}</p>
|
||||||
|
<p><strong>Last Seen:</strong> {{ device.last_seen }}</p>
|
||||||
|
{% if device.user_agent %}
|
||||||
|
<p><strong>User Agent:</strong>
|
||||||
|
<small class="text-muted">{{ device.user_agent }}</small>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button id="update-name-btn" class="btn btn-primary" onclick="showNameModal()">
|
||||||
|
{% if device %}
|
||||||
|
Update Device Name
|
||||||
|
{% else %}
|
||||||
|
Name This Device
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if fingerprint_hash %}
|
||||||
|
<button class="btn btn-outline-secondary ms-2" onclick="refreshFingerprint()">
|
||||||
|
Refresh Fingerprint
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if fingerprint_hash %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2">Generating device fingerprint...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for updating device name -->
|
||||||
|
<div class="modal fade" id="nameModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
{% if device %}
|
||||||
|
Update Device Name
|
||||||
|
{% else %}
|
||||||
|
Name This Device
|
||||||
|
{% endif %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="device-name-input" class="form-label">Device Name</label>
|
||||||
|
<input type="text"
|
||||||
|
id="device-name-input"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Enter a name for this device"
|
||||||
|
{% if device and device.device_name %}value="{{ device.device_name }}"{% endif %}>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
This name will be displayed when you visit from this device.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="updateDeviceName()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let currentFingerprint = '{{ fingerprint_hash }}';
|
||||||
|
|
||||||
|
// Generate fingerprint
|
||||||
|
async function generateFingerprint() {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.font = '14px Arial';
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
ctx.fillStyle = '#f60';
|
||||||
|
ctx.fillRect(125, 1, 62, 20);
|
||||||
|
ctx.fillStyle = '#069';
|
||||||
|
ctx.fillText('Device fingerprint', 2, 15);
|
||||||
|
const canvasFingerprint = canvas.toDataURL();
|
||||||
|
|
||||||
|
// Get available fonts (simplified)
|
||||||
|
const fonts = ['Arial', 'Helvetica', 'Times New Roman', 'Courier', 'Verdana', 'Georgia', 'Palatino', 'Garamond', 'Bookman', 'Tahoma'];
|
||||||
|
|
||||||
|
const fingerprintData = {
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
screen_width: screen.width,
|
||||||
|
screen_height: screen.height,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
language: navigator.language,
|
||||||
|
platform: navigator.platform,
|
||||||
|
canvas_fingerprint: canvasFingerprint,
|
||||||
|
fonts: fonts
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/fingerprint', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(fingerprintData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
currentFingerprint = result.fingerprint_hash;
|
||||||
|
|
||||||
|
// Reload page to show updated info
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
console.error('Failed to generate fingerprint:', response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating fingerprint:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNameModal() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('nameModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDeviceName() {
|
||||||
|
const nameInput = document.getElementById('device-name-input');
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
nameInput.classList.add('is-invalid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nameInput.classList.remove('is-invalid');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/device/name', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-fingerprint': currentFingerprint
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ device_name: name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('nameModal'));
|
||||||
|
modal.hide();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
console.error('Failed to update device name:', response.statusText);
|
||||||
|
alert('Failed to update device name. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating device name:', error);
|
||||||
|
alert('Error updating device name. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFingerprint() {
|
||||||
|
if (confirm('This will generate a new fingerprint for your device. Continue?')) {
|
||||||
|
generateFingerprint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate fingerprint on page load if not already done
|
||||||
|
if (!currentFingerprint) {
|
||||||
|
generateFingerprint();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Admin - Registered Devices{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>Registered Devices</h1>
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-secondary">Total: {{ devices }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if devices %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Device Name</th>
|
||||||
|
<th>Fingerprint</th>
|
||||||
|
<th>First Seen</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
<th>User Agent</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for device in devices %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if device.device_name %}
|
||||||
|
<strong>{{ device.device_name }}</strong>
|
||||||
|
{% else %}
|
||||||
|
<em class="text-muted">Unnamed Device</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code class="small">{{ device.fingerprint_hash }}...</code>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-1"
|
||||||
|
onclick="copyToClipboard('{{ device.fingerprint_hash }}')"
|
||||||
|
title="Copy full fingerprint">
|
||||||
|
📋
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ device.first_seen }}</td>
|
||||||
|
<td class="small">{{ device.last_seen }}</td>
|
||||||
|
<td class="small text-muted" style="max-width: 300px;">
|
||||||
|
{% if device.user_agent %}
|
||||||
|
<div class="text-truncate" title="{{ device.user_agent }}">
|
||||||
|
{{ device.user_agent }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<em>Unknown</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-info"
|
||||||
|
onclick="showDeviceDetails('{{ device.id }}', '{{ device.fingerprint_hash }}', '{{ device.device_name }}', '{{ device.first_seen }}', '{{ device.last_seen }}', '{{ device.user_agent }}')"
|
||||||
|
title="View Details">
|
||||||
|
👁️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3>Statistics</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-primary text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Total Devices</h5>
|
||||||
|
<h2>{{ devices }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-success text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Named Devices</h5>
|
||||||
|
<h2>{{ named_devices_count }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-warning text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Recent (24h)</h5>
|
||||||
|
<h2>{{ recent_devices_count }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-info text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>This Week</h5>
|
||||||
|
<h2>{{ weekly_devices_count }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<h3 class="text-muted">No Devices Registered</h3>
|
||||||
|
<p class="text-muted">Visit the <a href="/">home page</a> to register your first device.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Details Modal -->
|
||||||
|
<div class="modal fade" id="deviceModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Device Details</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="device-details-content">
|
||||||
|
<!-- Content will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
// Could add a toast notification here
|
||||||
|
console.log('Fingerprint copied to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDeviceDetails(id, fingerprint, name, firstSeen, lastSeen, userAgent) {
|
||||||
|
const content = document.getElementById('device-details-content');
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Device ID:</strong></td>
|
||||||
|
<td><code>${id}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Device Name:</strong></td>
|
||||||
|
<td>${name || '<em>Unnamed</em>'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Fingerprint Hash:</strong></td>
|
||||||
|
<td>
|
||||||
|
<code style="word-break: break-all;">${fingerprint}</code>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-2"
|
||||||
|
onclick="copyToClipboard('${fingerprint}')"
|
||||||
|
title="Copy fingerprint">
|
||||||
|
📋 Copy
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>First Seen:</strong></td>
|
||||||
|
<td>${firstSeen}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Last Seen:</strong></td>
|
||||||
|
<td>${lastSeen}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>User Agent:</strong></td>
|
||||||
|
<td style="word-break: break-word;">${userAgent || '<em>Unknown</em>'}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('deviceModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -5,9 +5,29 @@
|
||||||
Full name: {{ profile.name }}<br/>
|
Full name: {{ profile.name }}<br/>
|
||||||
Given name: {{ profile.given_name }}<br/>
|
Given name: {{ profile.given_name }}<br/>
|
||||||
Family name: {{ profile.family_name }}<br/>
|
Family name: {{ profile.family_name }}<br/>
|
||||||
Your email address: {{ profile.email }}<br/>
|
Logged in email address: {{ profile.email }}<br/>
|
||||||
<br/>
|
<br/>
|
||||||
<h2>User Roles</h2>
|
<h2>Accounts</h2>
|
||||||
|
<button type="button" class="btn btn-primary">Merge</button>
|
||||||
|
<table class="table table-striped table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col">Email</th>
|
||||||
|
<th scope="col">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user_account in profile_accounts %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/user/{{profile.id}}/{{ user_account.id }}">{{ account.name }}</a></td>
|
||||||
|
<td>{{ user_account.email }}</td>
|
||||||
|
<td><a href="/account/{{ user_account.id }}/delete">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h2>Account Roles ({{ account.email }})</h2>
|
||||||
<button type="button" class="btn btn-primary">Edit</button>
|
<button type="button" class="btn btn-primary">Edit</button>
|
||||||
<button type="button" class="btn btn-primary">Add</button>
|
<button type="button" class="btn btn-primary">Add</button>
|
||||||
<table class="table table-striped table-bordered">
|
<table class="table table-striped table-bordered">
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,17 @@
|
||||||
{% block title %}User Administration{% endblock %}
|
{% block title %}User Administration{% endblock %}
|
||||||
{% block center %}
|
{% block center %}
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
<table class="table table-striped table-bordered">
|
<table data-toggle="table" data-height="400" class="table table-striped table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Name</th>
|
<th data-sortable="true" data-field="name" scope="col">Name</th>
|
||||||
<th scope="col">email</th>
|
<th data-sortable="true" data-field="email" scope="col">email</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/users/{{ user.id }}">{{ user.name }}</a></td>
|
<td><a href="/user/{{ user.id }}">{{ user.name }}</a></td>
|
||||||
<td>{{ user.email }}</td>
|
<td>{{ user.email }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,71 @@
|
||||||
{% extends "authorized.html" %}
|
{% extends "authorized.html" %}
|
||||||
{% block title %}User Profile{% endblock %}
|
{% block title %}User Wishlist{% endblock %}
|
||||||
{% block center %}
|
{% block center %}
|
||||||
{% if my_wishlist %}
|
{% if my_wishlist %}
|
||||||
<h1>My Wishlist</h1>
|
<h1>My Wishlist</h1>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1>{{ user.given_name }} Wishlist</h1>
|
<h1>{{ person.given_name }} Wishlist</h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<br/>
|
<br />
|
||||||
<h2>List</h2>
|
<h2>List</h2>
|
||||||
{% if my_wishlist %}
|
{% if my_wishlist %}
|
||||||
<a href="/userwishlist/add/{{ user.id }}">Add</a>
|
<a href="/userwishlist/add/{{ person.id }}">Add</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="table-responsive overflow-auto">
|
<div class="table-responsive overflow-auto">
|
||||||
<table class="table table-striped table-bordered">
|
<table data-toggle="table" class="table table-striped table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Item</th>
|
<th data-sortable="true" data-field="item" scope="col">Item</th>
|
||||||
<th scope="col">Link</th>
|
<th scope="col">Link</th>
|
||||||
<th scope="col">State</th>
|
<th data-sortable="true" data-field="state" scope="col">State</th>
|
||||||
<th scope="col">Action</th>
|
<th scope="col">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for user_wishlist_item in user_wishlist_items %}
|
{% for person_wishlist_item in person_wishlist_items %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if my_wishlist %}
|
{% if my_wishlist %}
|
||||||
<td><a href="/userwishlist/edit/{{ user_wishlist_item.id }}">{{ user_wishlist_item.item }}</a></td>
|
<td><a href="/userwishlist/edit/{{ person_wishlist_item.id }}">{{ person_wishlist_item.item }}</a></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td>{{ user_wishlist_item.item }}</td>
|
<td>{{ person_wishlist_item.item }}</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td><a href="{{ user_wishlist_item.item_url }}">URL</a></td>
|
|
||||||
{% if user_wishlist_item.received_at > 0 %}
|
{% if person_wishlist_item.item_url.len() > 0 %}
|
||||||
<td>Got it!</td>
|
<td><a href="{{ person_wishlist_item.item_url }}">URL</a></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<td></td>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% match person_wishlist_item.received_at %}
|
||||||
|
{% when None %}
|
||||||
<td>Not yet!</td>
|
<td>Not yet!</td>
|
||||||
{% endif %}
|
{% when Some with (received_at) %}
|
||||||
{% if my_wishlist %}
|
<td>Got it!</td>
|
||||||
{% if user_wishlist_item.received_at > 0 %}
|
{% endmatch %}
|
||||||
<td>Got it!</td>
|
|
||||||
|
{% if my_wishlist %}
|
||||||
|
{% match person_wishlist_item.received_at %}
|
||||||
|
{% when None %}
|
||||||
|
<td><a href="/userwishlist/received/{{ person_wishlist_item.id }}">Received</a></td>
|
||||||
|
{% when Some with (received_at) %}
|
||||||
|
<td><a href="/userwishlist/delete/{{ person_wishlist_item.id }}">Delete</a></td>
|
||||||
|
{% endmatch %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<td><a href="/userwishlist/received/{{ user_wishlist_item.id }}">Received</a></td>
|
{% match person_wishlist_item.purchased_by %}
|
||||||
|
{% when Some with (purchased_by) %}
|
||||||
|
{% if purchased_by.clone() == user.id %}
|
||||||
|
<td><a href="/userwishlist/returned/{{ person_wishlist_item.id }}">Return</a></td>
|
||||||
|
{% else %}
|
||||||
|
<td>Purchased</td>
|
||||||
|
{% endif %}
|
||||||
|
{% when None %}
|
||||||
|
<td><a href="/userwishlist/bought/{{ person_wishlist_item.id }}">Bought</a></td>
|
||||||
|
{% endmatch %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
|
||||||
{% if user_wishlist_item.purchased_by > 0 %}
|
</tr>
|
||||||
<td>Purchased</td>
|
{% endfor %}
|
||||||
{% else %}
|
</tbody>
|
||||||
<td><a href="/userwishlist/bought/{{ user_wishlist_item.id }}">Bought</a></td>
|
</table>
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock center %}
|
{% endblock center %}
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
{% block title %}Wish Lists{% endblock %}
|
{% block title %}Wish Lists{% endblock %}
|
||||||
{% block center %}
|
{% block center %}
|
||||||
<h1>Wishlists</h1>
|
<h1>Wishlists</h1>
|
||||||
<table class="table table-striped table-bordered">
|
<table data-toggle="table" class="table table-striped table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Name</th>
|
<th data-sortable="true" data-field="name" scope="col">Name</th>
|
||||||
<th scope="col">email</th>
|
<th data-sortable="true" data-field="email" scope="col">email</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
cargo build --release
|
cargo build --release
|
||||||
ssh www@192.168.59.11 'pkill jean-marie'
|
|
||||||
scp target/release/jean-marie www@192.168.59.11:/opt/jean-marie
|
scp target/release/jean-marie www@192.168.59.11:/opt/jean-marie
|
||||||
scp runsite.sh www@192.168.59.11:/opt/jean-marie
|
#scp prod.env www@192.168.59.11:/opt/jean-marie
|
||||||
scp .env www@192.168.59.11:/opt/jean-marie
|
|
||||||
scp -r templates www@192.168.59.11:/opt/jean-marie
|
scp -r templates www@192.168.59.11:/opt/jean-marie
|
||||||
ssh www@192.168.59.11 '/opt/jean-marie/runsite.sh'
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@ cargo build --release
|
||||||
ssh chris@192.168.59.31 'pkill jean-marie'
|
ssh chris@192.168.59.31 'pkill jean-marie'
|
||||||
scp target/release/jean-marie chris@192.168.59.31:/opt/jean-marie
|
scp target/release/jean-marie chris@192.168.59.31:/opt/jean-marie
|
||||||
scp runsite.sh chris@192.168.59.31:/opt/jean-marie
|
scp runsite.sh chris@192.168.59.31:/opt/jean-marie
|
||||||
scp .env chris@192.168.59.31:/opt/jean-marie
|
scp test.env chris@192.168.59.31:/opt/jean-marie/.env
|
||||||
scp -r templates chris@192.168.59.31:/opt/jean-marie
|
scp -r templates chris@192.168.59.31:/opt/jean-marie
|
||||||
ssh chris@192.168.59.31 '/opt/jean-marie/runsite.sh'
|
ssh chris@192.168.59.31 '/opt/jean-marie/runsite.sh'
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
proxy=99.232.244.189
|
||||||
|
cargo build --release
|
||||||
|
scp target/release/jean-marie chris@$proxy:development/jean-marie/backend/target/release
|
||||||
|
scp -r templates chris@$proxy:development/jean-marie/backend
|
||||||
|
|
||||||
Loading…
Reference in New Issue