Compare commits

...

20 Commits

Author SHA1 Message Date
Chris Jean-Marie 3b16cce62a Initial device fingerprinting and auto login 2025-09-28 04:02:02 +00:00
Chris Jean-Marie 50bc1f4eb5 Add admin changes to calendar requests 2025-06-23 17:54:14 +00:00
Chris Jean-Marie e59f849abd Modify code promotion scripts 2025-06-23 17:53:36 +00:00
Chris Jean-Marie d156cbfa98 Allow celendar event editing 2025-06-20 14:48:37 +00:00
Chris Jean-Marie fe8056179b Add initial calendar rbac roles 2025-06-15 01:26:34 +00:00
Chris Jean-Marie 904cd4315c Initial RBAC for calendar use 2025-06-10 21:27:33 +00:00
Chris Jean-Marie e9edd3e82a Fix cottage calendar requests 2025-06-06 14:33:20 +00:00
Chris Jean-Marie 8032019807 Fix wishlist for new people table 2025-06-02 23:55:25 +00:00
Chris Jean-Marie 682ea672c4 Continue calendar code 2025-06-01 03:14:28 +00:00
Chris Jean-Marie f7e643ffbd Split environments 2025-05-14 02:24:09 +00:00
Chris Jean-Marie fd2ec59d33 Change copywrite 2025-05-14 02:23:47 +00:00
Chris Jean-Marie c5100f31e5 Cleanup use statements 2025-05-14 02:23:31 +00:00
Chris Jean-Marie d09b280061 Add files to .gitignore 2025-04-12 23:55:21 +00:00
Chris Jean-Marie 6bdf37e91a Cleanup database migration script 2025-04-10 14:36:32 +00:00
Chris Jean-Marie 6552b1248f Add postgresql database instructions 2025-04-10 14:36:17 +00:00
Chris Jean-Marie a8ca9bfafe Move wishlists to the profile 2025-03-26 13:35:51 +00:00
Chris Jean-Marie 1589ebfd37 Upgrade to latest cargo packages
Refactor code to use the person table
2025-03-25 18:34:07 +00:00
Chris Jean-Marie c9dd17ae14 Move user to person 2025-03-01 00:27:25 +00:00
Chris Jean-Marie 462716633e Get calendar events from database 2024-12-14 04:57:13 +00:00
chris 939f87d820 Merge pull request 'postgres-conversion' (#3) from postgres-conversion into main
Reviewed-on: #3
2024-12-13 17:44:37 +00:00
53 changed files with 3564 additions and 899 deletions

View File

@ -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).

3
.gitignore vendored
View File

@ -4,3 +4,6 @@ backend/id_rsa
backend/id_rsa.pub backend/id_rsa.pub
backend/sqlite3.env backend/sqlite3.env
backend/.env backend/.env
backend/prod.env
backend/test.env
backend/dev.env

View File

@ -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
} }

View File

@ -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

1309
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
@ -29,3 +29,7 @@ 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"

190
backend/KioskNotes.txt Normal file
View File

@ -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(&params.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.

View File

@ -0,0 +1,5 @@
-- Remove person level tables
ALTER TABLE if exists users
drop column if exists person_id;
drop table if exists people;

View File

@ -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;

View File

@ -0,0 +1,3 @@
-- Remove column from calendar_events
ALTER TABLE if exists calendar_events
drop column if exists state;

View File

@ -0,0 +1,3 @@
-- Add column to calendar_events
ALTER TABLE if exists calendar_events
ADD COLUMN IF NOT EXISTS state character varying(25);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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.';

View File

@ -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);

View File

@ -0,0 +1 @@
-- Add down migration script here

View File

@ -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';

View File

@ -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;

View File

@ -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';

View File

@ -0,0 +1,2 @@
-- Delete all fingerprint items
drop table if exists devices;

View File

@ -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();

View File

@ -0,0 +1 @@
DROP TABLE if exists kiosk_devices;

View File

@ -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
);

View File

@ -1,16 +1,21 @@
use askama::Template; use askama::Template;
use askama_axum::{IntoResponse, Response};
use axum::{ use axum::{
extract::{Path, 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::PgPool; 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,7 +36,9 @@ where
} }
} }
struct Calendar { #[derive(Default, Clone, Debug, Serialize, Deserialize, FromRow)]
pub struct Calendar {
id: Uuid,
name: String, name: String,
colour: String, colour: String,
} }
@ -40,37 +47,43 @@ struct Calendar {
#[template(path = "calendar.html")] #[template(path = "calendar.html")]
struct CalendarTemplate { struct CalendarTemplate {
logged_in: bool, logged_in: bool,
user: UserData, user: AccountData,
user_roles: Vec<crate::user::UserRolesDisplay>, user_roles: Vec<crate::user::UserRolesDisplay>,
calendars: Vec<Calendar>, calendars: Vec<Calendar>,
} }
pub async fn calendar( pub async fn calendar(
Extension(user_data): Extension<Option<UserData>>, Extension(user_data): Extension<Option<AccountData>>,
Extension(rbac): Extension<RbacService>,
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
let mut is_authorized = false;
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(); let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
if is_authorized("/calendar", user_data, db_pool.clone()).await { 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 {
// 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 calendars: Vec<Calendar> = vec![ let calendars: Vec<Calendar> = get_calendars(db_pool.clone()).await;
Calendar {
name: "Cottage".to_string(),
colour: "green".to_string(),
},
Calendar {
name: "Personal".to_string(),
colour: "blue".to_string(),
},
];
let template = CalendarTemplate { let template = CalendarTemplate {
logged_in, logged_in,
@ -87,28 +100,531 @@ pub async fn calendar(
} }
} }
#[derive(Deserialize, Serialize, Debug)]
pub struct EventParams {
start: String,
end: String,
}
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( pub async fn get_events(
Path(calendar): Path<String>,
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
Extension(user_data): Extension<Option<UserData>>, Query(params): Query<EventParams>,
Extension(user_data): Extension<Option<AccountData>>,
Extension(rbac): Extension<RbacService>,
) -> String { ) -> String {
println!("Calendar: {}", calendar); //println!("Paramters: {:?}", params);
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
// Set default events // Set default events
let mut 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(&params.start).unwrap())
.bind(chrono::DateTime::parse_from_rfc3339(&params.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 { 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(); 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(&params.start, fmt).unwrap();
let end_date = chrono::NaiveDate::parse_from_str(&params.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 { if is_authorized("/calendar", user_data, db_pool.clone()).await {
// Get requested calendar events from database // Get user roles
events = "[{\"title\": \"Chris and Terri\", \"start\": \"2024-12-23T14:00:00\", \"end\": \"2024-12-27T10:00:00\", \"allDay\": false}, {\"title\": \"Stephen\", \"start\": \"2024-12-27T14:00:00\", \"end\": \"2024-12-31T10:00:00\", \"allDay\": false}]"; let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
} let calendars: Vec<Calendar> = get_calendars(db_pool.clone()).await;
}
events.to_string() let template = EventTemplate {
logged_in,
user,
user_roles,
calendars,
};
HtmlTemplate(template).into_response()
} else {
Redirect::to("/").into_response()
}
} else {
Redirect::to("/").into_response()
}
} }

338
backend/src/fingerprint.rs Normal file
View File

@ -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,
})
}

View File

@ -5,9 +5,9 @@
// GOOGLE_CLIENT_SECRET=yyy // GOOGLE_CLIENT_SECRET=yyy
use axum::{ use axum::{
extract::{Extension, Host, Query, State}, response::{IntoResponse, Redirect} extract::{Extension, Query, State}, 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};
@ -21,10 +21,10 @@ use sqlx::PgPool;
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")?);
@ -58,7 +58,7 @@ 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<PgPool>, State(db_pool): State<PgPool>,
Host(hostname): Host, Host(hostname): Host,
@ -88,9 +88,12 @@ pub async fn login(
.set_pkce_challenge(pkce_code_challenge) .set_pkce_challenge(pkce_code_challenge)
.url(); .url();
sqlx::query!( sqlx::query(
"INSERT INTO oauth2_state_storage (csrf_state, pkce_code_verifier, return_url) VALUES ($1, $2, $3);",csrf_state.secret(), pkce_code_verifier.secret(), return_url "INSERT INTO oauth2_state_storage (csrf_state, pkce_code_verifier, return_url) VALUES ($1, $2, $3);"
) )
.bind(csrf_state.secret())
.bind(pkce_code_verifier.secret())
.bind(return_url)
.execute(&db_pool) .execute(&db_pool)
.await .await
.map_err(|e| { .map_err(|e| {
@ -193,8 +196,8 @@ pub async fn google_auth_return(
let user_id = if let Ok(query) = query { let user_id = if let Ok(query) = query {
query.0 query.0
} else { } else {
// Add user // Add person
let query: (uuid::Uuid,) = sqlx::query_as(r#"INSERT INTO users (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"#) 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"#)
.bind(email.clone()) .bind(email.clone())
.bind(name.clone()) .bind(name.clone())
.bind(family_name.clone()) .bind(family_name.clone())
@ -202,9 +205,19 @@ 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(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'))"#) 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(query.0) .bind(user.0)
.execute(&db_pool) .execute(&db_pool)
.await?; .await?;
@ -213,7 +226,7 @@ pub async fn google_auth_return(
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); 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); 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

100
backend/src/kiosk.rs Normal file
View File

@ -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)))
}

View File

@ -1,15 +1,15 @@
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 dotenvy::var; use dotenvy::var;
use secret_gift_exchange::{giftexchange, giftexchange_save, giftexchanges}; use secret_gift_exchange::{giftexchange, giftexchange_save, giftexchanges};
use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, sqlite::SqliteRow, Row, SqlitePool}; use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, Row, SqlitePool};
use sqlx::{postgres::PgPoolOptions, PgPool}; use sqlx::{postgres::PgPoolOptions, PgPool};
use std::net::SocketAddr; use std::{f32::consts::E, net::SocketAddr};
use tower_http::services::ServeDir; use tower_http::{services::ServeDir, trace::TraceLayer};
mod fingerprint;
mod calendar; mod calendar;
mod email; mod email;
mod error_handling; mod error_handling;
@ -19,23 +19,32 @@ mod routes;
mod secret_gift_exchange; mod secret_gift_exchange;
mod user; mod user;
mod wishlist; mod wishlist;
mod rbac;
mod kiosk;
use calendar::{calendar, get_events}; 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_delete_item, user_wishlist_edit_item, user_wishlist_received_item, user_wishlist_delete_item, user_wishlist_edit_item, user_wishlist_received_item,
user_wishlist_returned_item, user_wishlist_save_item, wishlists, 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: PgPool, db: PgPool,
} }
#[tokio::main] #[tokio::main]
@ -47,12 +56,12 @@ async fn main() {
let database_url = var("DATABASE_URL").expect("DATABASE_URL not set"); let database_url = var("DATABASE_URL").expect("DATABASE_URL not set");
let db_pool = PgPoolOptions::new().connect(&database_url).await.unwrap(); let db_pool = PgPoolOptions::new().connect(&database_url).await.unwrap();
let app_state = AppState { db_pool: db_pool }; let app_state = AppState { db: 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");
@ -64,59 +73,83 @@ async fn main() {
.await .await
.unwrap(); .unwrap();
copy_database(&sdb_pool, &app_state.db_pool).await; copy_database(&sdb_pool, &app_state.db).await;
} else { } else {
println!("SOURCE_DB_URL not set"); println!("SOURCE_DB_URL not set");
} }
let user_data: Option<UserData> = None; 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("/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 // 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 // Calendar
.route("/calendar", get(calendar)) .route("/calendar", get(calendar))
.route("/getevents/:calendar", get(get_events)) .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 // 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( .route(
"/userwishlist/delete/:item_id", "/userwishlist/delete/{item_id}",
get(user_wishlist_delete_item), get(user_wishlist_delete_item),
) )
.route( .route(
"/userwishlist/returned/:item_id", "/userwishlist/returned/{item_id}",
get(user_wishlist_returned_item), get(user_wishlist_returned_item),
) )
// Secret Gift Exchange - Not ready for public use yet // Secret Gift Exchange - Not ready for public use yet
.route("/giftexchanges", get(giftexchanges)) .route("/giftexchanges", get(giftexchanges))
.route( .route(
"/giftexchange/:giftexchange_id", "/giftexchange/{giftexchange_id}",
get(giftexchange).post(giftexchange_save), get(giftexchange).post(giftexchange_save),
) )
.nest_service( .nest_service(
@ -131,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;
@ -314,4 +351,94 @@ async fn copy_database(sdb_pool: &SqlitePool, db_pool: &PgPool) {
println!("Error: {}", e); 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)
} }

View File

@ -1,6 +1,6 @@
use std::path::Path; use std::path::Path;
use super::{AppError, UserData}; use super::{AppError, AccountData};
use axum::{body::Body, extract::State, http::Request, middleware::Next, response::IntoResponse}; use axum::{body::Body, extract::State, http::Request, middleware::Next, response::IntoResponse};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use chrono::Utc; use chrono::Utc;
@ -39,15 +39,15 @@ 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().naive_local() { if expires_at > Utc::now().naive_local() {
let row: UserData = sqlx::query_as( let row: AccountData = sqlx::query_as(
r#"SELECT id, created_at, created_by, updated_at, updated_by, email, name, family_name, given_name FROM users WHERE id = $1"#, r#"SELECT id, created_at, created_by, updated_at, updated_by, email, name, family_name, given_name, person_id FROM users WHERE id = $1"#,
) )
.bind(id) .bind(id)
.fetch_one(&db_pool) .fetch_one(&db_pool)
.await .await
.unwrap(); .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,
@ -57,6 +57,7 @@ pub async fn inject_user_data(
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,
})); }));
} }
} }
@ -69,7 +70,7 @@ 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: PgPool) -> 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<(uuid::Uuid,), _> = match path { let query: Result<(uuid::Uuid,), _> = match path {
"/profile" => { "/profile" => {

47
backend/src/rbac.rs Normal file
View File

@ -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 == "*")
}

View File

@ -1,4 +1,4 @@
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},
@ -6,18 +6,18 @@ use axum::{
}; };
use http::StatusCode; use http::StatusCode;
use sqlx::PgPool; 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,
user: UserData, user: AccountData,
user_roles: Vec<crate::user::UserRolesDisplay>, user_roles: Vec<crate::user::UserRolesDisplay>,
} }
@ -25,20 +25,22 @@ struct ProfileTemplate {
#[template(path = "user.html")] #[template(path = "user.html")]
struct UserProfileTemplate { struct UserProfileTemplate {
logged_in: bool, logged_in: bool,
user: UserData, 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) => (
@ -54,21 +56,21 @@ where
#[template(path = "index.html")] #[template(path = "index.html")]
struct IndexTemplate { struct IndexTemplate {
logged_in: bool, logged_in: bool,
user: UserData, user: AccountData,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "dashboard.html")] #[template(path = "dashboard.html")]
struct DashboardTemplate { struct DashboardTemplate {
logged_in: bool, logged_in: bool,
user: UserData, user: AccountData,
user_roles: Vec<crate::user::UserRolesDisplay>, user_roles: Vec<crate::user::UserRolesDisplay>,
fire_rating: String, fire_rating: String,
} }
pub async fn index( pub async fn index(
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
Extension(user_data): Extension<Option<UserData>>, Extension(user_data): Extension<Option<AccountData>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
@ -86,7 +88,7 @@ pub async fn index(
} else { } else {
let template = IndexTemplate { let template = IndexTemplate {
logged_in, logged_in,
user: UserData::default(), user: AccountData::default(),
}; };
HtmlTemplate(template).into_response() HtmlTemplate(template).into_response()
} }
@ -94,7 +96,7 @@ pub async fn index(
pub async fn dashboard( pub async fn dashboard(
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
Extension(user_data): Extension<Option<UserData>>, Extension(user_data): Extension<Option<AccountData>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
@ -128,7 +130,7 @@ pub async fn dashboard(
/// Handles the profile page. /// Handles the profile page.
pub async fn profile( pub async fn profile(
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
Extension(user_data): Extension<Option<UserData>>, Extension(user_data): Extension<Option<AccountData>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
@ -160,7 +162,45 @@ pub async fn profile(
pub async fn user_profile( pub async fn user_profile(
Path(user_id): Path<uuid::Uuid>, Path(user_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
Extension(user_data): Extension<Option<UserData>>, 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.
//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: PersonData = 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 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();
let redirect_url = format!("/user/{user_id}/{}", profile_account);
Redirect::to(&redirect_url).into_response()
} else {
Redirect::to("/").into_response()
}
} else {
Redirect::to("/").into_response()
}
}
pub async fn user_profile_account(
Path((user_id, account_id)): Path<(uuid::Uuid, uuid::Uuid)>,
State(db_pool): State<PgPool>,
Extension(user_data): Extension<Option<AccountData>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
@ -171,7 +211,7 @@ pub async fn user_profile(
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();
// Extract the user data. // Extract the user data.
let profile = sqlx::query_as( "SELECT * FROM users WHERE id = $1") let profile = sqlx::query_as( "SELECT * FROM people WHERE id = $1")
.bind(user_id) .bind(user_id)
.fetch_one(&db_pool) .fetch_one(&db_pool)
.await .await
@ -181,11 +221,36 @@ pub async fn user_profile(
// Get logged in user roles // Get logged in 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 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 // Get user roles
let profile_roles = get_user_roles_display(user_id, &db_pool.clone()).await; let profile_roles = get_user_roles_display(account_id, &db_pool.clone()).await;
// Get roles user does not have // Get roles user does not have
let non_profile_roles = get_other_roles_display(user_id, &db_pool.clone()).await; let non_profile_roles = get_other_roles_display(account_id, &db_pool.clone()).await;
// Create the profile template. // Create the profile template.
let template = UserProfileTemplate { let template = UserProfileTemplate {
@ -193,6 +258,8 @@ pub async fn user_profile(
user, user,
user_roles, user_roles,
profile, profile,
profile_accounts,
account,
profile_roles, profile_roles,
non_profile_roles, non_profile_roles,
}; };
@ -209,13 +276,13 @@ pub async fn user_profile(
#[template(path = "useradmin.html")] #[template(path = "useradmin.html")]
struct UserAdminTemplate { struct UserAdminTemplate {
logged_in: bool, logged_in: bool,
user: UserData, user: AccountData,
user_roles: Vec<crate::user::UserRolesDisplay>, user_roles: Vec<crate::user::UserRolesDisplay>,
users: Vec<UserData>, 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<PgPool>, State(db_pool): State<PgPool>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
@ -226,7 +293,7 @@ pub async fn useradmin(
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(); let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
let users = sqlx::query_as::<_, UserData>("SELECT * FROM users") let users = sqlx::query_as::<_, PersonData>("SELECT * FROM people order by name")
.fetch_all(&db_pool) .fetch_all(&db_pool)
.await .await
.unwrap(); .unwrap();
@ -254,15 +321,15 @@ pub async fn useradmin(
#[template(path = "about.html")] #[template(path = "about.html")]
struct AboutTemplate { struct AboutTemplate {
logged_in: bool, logged_in: bool,
user: UserData, 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 {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
// Set empty user // Set empty user
let mut user = UserData::default(); let mut user = AccountData::default();
if logged_in { if logged_in {
// Extract the user data. // Extract the user data.
@ -277,15 +344,15 @@ 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,
user: UserData, 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 {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
// Set empty user // Set empty user
let mut user = UserData::default(); let mut user = AccountData::default();
if logged_in { if logged_in {
// Extract the user data. // Extract the user data.

View File

@ -1,19 +1,17 @@
use askama::Template; use askama::Template;
use askama_axum::{IntoResponse, Response};
use axum::{ use axum::{
body::{self, Body}, body::{self, Body},
extract::{FromRequest, Path, Request, State}, extract::{Path, Request, State},
response::Redirect, response::{Html, IntoResponse, Redirect, Response},
Extension, Form, Json, RequestExt, Extension,
}; };
use axum_extra::response::Html;
use http::{header::CONTENT_TYPE, StatusCode}; use http::{header::CONTENT_TYPE, StatusCode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool};
use crate::{ use crate::{
middlewares::is_authorized, middlewares::is_authorized,
user::{get_user_roles_display, UserData}, user::{get_user_roles_display, AccountData, PersonData},
}; };
/// Select participants from user list /// Select participants from user list
@ -89,13 +87,13 @@ struct GiftExchange {
#[template(path = "giftexchanges.html")] #[template(path = "giftexchanges.html")]
struct GiftExchangesTemplate { struct GiftExchangesTemplate {
logged_in: bool, logged_in: bool,
user: UserData, user: AccountData,
user_roles: Vec<crate::user::UserRolesDisplay>, user_roles: Vec<crate::user::UserRolesDisplay>,
giftexchanges: Vec<GiftExchange>, giftexchanges: Vec<GiftExchange>,
} }
pub async fn giftexchanges( pub async fn giftexchanges(
Extension(user_data): Extension<Option<UserData>>, Extension(user_data): Extension<Option<AccountData>>,
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
@ -146,16 +144,16 @@ struct GiftExchangeParticipant {
#[template(path = "giftexchange.html")] #[template(path = "giftexchange.html")]
struct GiftExchangeTemplate { struct GiftExchangeTemplate {
logged_in: bool, logged_in: bool,
user: UserData, user: AccountData,
user_roles: Vec<crate::user::UserRolesDisplay>, user_roles: Vec<crate::user::UserRolesDisplay>,
giftexchange: GiftExchange, giftexchange: GiftExchange,
participants: Vec<UserData>, participants: Vec<PersonData>,
non_participants: Vec<UserData>, non_participants: Vec<PersonData>,
} }
pub async fn giftexchange( pub async fn giftexchange(
Path(exchange_id): Path<i64>, Path(exchange_id): Path<i64>,
Extension(user_data): Extension<Option<UserData>>, Extension(user_data): Extension<Option<AccountData>>,
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
@ -182,7 +180,7 @@ pub async fn giftexchange(
}; };
// Get participants // Get participants
let participants = sqlx::query_as::<_, UserData>( let participants = sqlx::query_as::<_, PersonData>(
"select * from users where users.id in (select participant_id from gift_exchange_participants where exchange_id = $1)", "select * from users where users.id in (select participant_id from gift_exchange_participants where exchange_id = $1)",
) )
.bind(exchange_id) .bind(exchange_id)
@ -191,7 +189,7 @@ pub async fn giftexchange(
.unwrap(); .unwrap();
// Get non participants // Get non participants
let non_participants = sqlx::query_as::<_, UserData>( 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)", "select * from users where users.id not in (select participant_id from gift_exchange_participants where exchange_id = $1)",
) )
.bind(exchange_id) .bind(exchange_id)

View File

@ -1,18 +1,18 @@
use askama_axum::IntoResponse;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Redirect, response::{IntoResponse, Redirect},
Extension, Extension,
}; };
///User related structs and functions ///User related structs and functions
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool}; 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: uuid::Uuid, pub id: uuid::Uuid,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
pub created_by: uuid::Uuid, pub created_by: uuid::Uuid,
@ -24,6 +24,20 @@ pub struct UserData {
pub given_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 name: String,
pub family_name: String,
pub given_name: String,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct RoleData { pub struct RoleData {
pub id: uuid::Uuid, pub id: uuid::Uuid,
@ -87,10 +101,13 @@ pub async fn get_user_roles(user_id: i64, db_pool: &PgPool) -> Vec<UserRoles> {
user_roles user_roles
} */ } */
pub async fn get_user_roles_display(user_id: uuid::Uuid, db_pool: &PgPool) -> 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 = $1"# 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)
@ -100,10 +117,13 @@ pub async fn get_user_roles_display(user_id: uuid::Uuid, db_pool: &PgPool) -> Ve
user_roles user_roles
} }
pub async fn get_other_roles_display(user_id: uuid::Uuid, db_pool: &PgPool) -> 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 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)"# 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)
@ -115,23 +135,28 @@ pub async fn get_other_roles_display(user_id: uuid::Uuid, db_pool: &PgPool) -> V
} }
pub async fn add_user_role( pub async fn add_user_role(
Path((user_id, role_id)): Path<(uuid::Uuid, uuid::Uuid)>, Path((account_id, role_id)): Path<(uuid::Uuid, uuid::Uuid)>,
State(db_pool): State<PgPool>, 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 {
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_by, updated_by, user_id, role_id) VALUES ($1, $2, $3, $4)")
.bind(user_data.as_ref().unwrap().id)// Created by current user .bind(user_data.as_ref().unwrap().id)// Created by current user
.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()
@ -139,9 +164,9 @@ 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<(uuid::Uuid, uuid::Uuid)>, Path((account_id, user_role_id)): Path<(uuid::Uuid, uuid::Uuid)>,
State(db_pool): State<PgPool>, 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 = $1") sqlx::query("DELETE FROM user_roles WHERE id = $1")
@ -150,14 +175,23 @@ pub async fn delete_user_role(
.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: uuid::Uuid, db_pool: &PgPool) -> 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
@ -171,7 +205,10 @@ pub async fn get_user_wishlist_item_by_id(item_id: uuid::Uuid, db_pool: &PgPool)
user_wishlist_item user_wishlist_item
} }
pub async fn get_user_wishlist_items(user_id: uuid::Uuid, db_pool: &PgPool) -> 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
@ -194,3 +231,23 @@ pub async fn get_useremails_by_role(role_name: String, db_pool: &PgPool) -> Stri
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()
}
}

View File

@ -1,10 +1,9 @@
use askama_axum::{IntoResponse, Response, Template}; use askama_axum::Template;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Redirect, response::{Html, IntoResponse, Redirect},
Extension, Form, Extension, Form,
}; };
use axum_extra::response::Html;
use chrono::Utc; use chrono::Utc;
use http::StatusCode; use http::StatusCode;
use serde::Deserialize; use serde::Deserialize;
@ -14,7 +13,7 @@ use uuid::Uuid;
use crate::{ use crate::{
middlewares::is_authorized, middlewares::is_authorized,
user::{ user::{
get_user_roles_display, get_user_wishlist_item_by_id, get_user_wishlist_items, UserData, get_user_roles_display, get_user_wishlist_item_by_id, get_user_wishlist_items, PersonData, AccountData,
UserWishlistItem, UserWishlistItem,
}, },
}; };
@ -25,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) => (
@ -41,13 +40,13 @@ where
#[template(path = "userwishlists.html")] #[template(path = "userwishlists.html")]
struct WishListsTemplate { struct WishListsTemplate {
logged_in: bool, logged_in: bool,
user: UserData, 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<PgPool>, State(db_pool): State<PgPool>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
@ -58,7 +57,10 @@ pub async fn wishlists(
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(); let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
let users = sqlx::query_as::<_, UserData>("SELECT * FROM users") 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) .fetch_all(&db_pool)
.await .await
.unwrap(); .unwrap();
@ -86,17 +88,17 @@ pub async fn wishlists(
#[template(path = "userwishlist.html")] #[template(path = "userwishlist.html")]
struct UserWishListTemplate { struct UserWishListTemplate {
logged_in: bool, logged_in: bool,
user: UserData, user: AccountData,
user_roles: Vec<crate::user::UserRolesDisplay>, user_roles: Vec<crate::user::UserRolesDisplay>,
my_wishlist: bool, my_wishlist: bool,
person: UserData, person: PersonData,
person_wishlist_items: Vec<crate::user::UserWishlistItem>, person_wishlist_items: Vec<crate::user::UserWishlistItem>,
} }
pub async fn user_wishlist( pub async fn user_wishlist(
Path(user_id): Path<uuid::Uuid>, Path(user_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
Extension(user_data): Extension<Option<UserData>>, Extension(user_data): Extension<Option<AccountData>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
@ -107,7 +109,7 @@ pub async fn user_wishlist(
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();
// Extract the user data. // Extract the user data.
let person = sqlx::query_as("SELECT * FROM users WHERE id = $1") let person = sqlx::query_as("SELECT * FROM people WHERE id = $1 or id = (select person_id from users where id = $1)")
.bind(user_id) .bind(user_id)
.fetch_one(&db_pool) .fetch_one(&db_pool)
.await .await
@ -121,7 +123,7 @@ pub async fn user_wishlist(
let person_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 {
@ -145,16 +147,16 @@ pub async fn user_wishlist(
#[template(path = "userwishlistadd.html")] #[template(path = "userwishlistadd.html")]
struct UserWishListAddTemplate { struct UserWishListAddTemplate {
logged_in: bool, logged_in: bool,
user: UserData, user: AccountData,
user_roles: Vec<crate::user::UserRolesDisplay>, user_roles: Vec<crate::user::UserRolesDisplay>,
person: UserData, person: PersonData,
person_wishlist_items: Vec<crate::user::UserWishlistItem>, person_wishlist_items: Vec<crate::user::UserWishlistItem>,
} }
pub async fn user_wishlist_add( pub async fn user_wishlist_add(
Path(user_id): Path<uuid::Uuid>, Path(user_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
Extension(user_data): Extension<Option<UserData>>, Extension(user_data): Extension<Option<AccountData>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
@ -165,7 +167,7 @@ pub async fn user_wishlist_add(
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();
// Extract the user data. // Extract the user data.
let person = sqlx::query_as("SELECT * FROM users WHERE id = $1") let person = sqlx::query_as("SELECT * FROM people WHERE id = $1")
.bind(user_id) .bind(user_id)
.fetch_one(&db_pool) .fetch_one(&db_pool)
.await .await
@ -204,7 +206,7 @@ pub struct ItemForm {
pub async fn user_wishlist_add_item( pub async fn user_wishlist_add_item(
Path(user_id): Path<uuid::Uuid>, Path(user_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, 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 {
@ -219,7 +221,9 @@ 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()
@ -230,7 +234,7 @@ pub async fn user_wishlist_add_item(
#[template(path = "userwishlistedit.html")] #[template(path = "userwishlistedit.html")]
struct UserWishListEditTemplate { struct UserWishListEditTemplate {
logged_in: bool, logged_in: bool,
user: UserData, user: AccountData,
user_roles: Vec<crate::user::UserRolesDisplay>, user_roles: Vec<crate::user::UserRolesDisplay>,
user_wishlist_item: crate::user::UserWishlistItem, user_wishlist_item: crate::user::UserWishlistItem,
} }
@ -238,7 +242,7 @@ struct UserWishListEditTemplate {
pub async fn user_wishlist_edit_item( pub async fn user_wishlist_edit_item(
Path(item_id): Path<uuid::Uuid>, Path(item_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, State(db_pool): State<PgPool>,
Extension(user_data): Extension<Option<UserData>>, Extension(user_data): Extension<Option<AccountData>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Is the user logged in? // Is the user logged in?
let logged_in = user_data.is_some(); let logged_in = user_data.is_some();
@ -275,7 +279,7 @@ pub async fn user_wishlist_edit_item(
pub async fn user_wishlist_save_item( pub async fn user_wishlist_save_item(
Path(item_id): Path<uuid::Uuid>, Path(item_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, 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 {
@ -292,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()
@ -303,7 +307,7 @@ pub async fn user_wishlist_save_item(
pub async fn user_wishlist_bought_item( pub async fn user_wishlist_bought_item(
Path(item_id): Path<uuid::Uuid>, Path(item_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, 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
@ -333,7 +337,7 @@ 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<uuid::Uuid>, Path(user_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, 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
@ -347,8 +351,8 @@ pub async fn user_wishlist_received_item(
.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() Redirect::to(&redirect_string).into_response()
} else { } else {
Redirect::to("/").into_response() Redirect::to("/").into_response()
@ -358,7 +362,7 @@ pub async fn user_wishlist_received_item(
pub async fn user_wishlist_delete_item( pub async fn user_wishlist_delete_item(
Path(item_id): Path<uuid::Uuid>, Path(item_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, 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 {
sqlx::query("delete from wishlist_items where id = $1") sqlx::query("delete from wishlist_items where id = $1")
@ -368,8 +372,8 @@ pub async fn user_wishlist_delete_item(
.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() Redirect::to(&redirect_string).into_response()
} else { } else {
Redirect::to("/").into_response() Redirect::to("/").into_response()
@ -379,7 +383,7 @@ pub async fn user_wishlist_delete_item(
pub async fn user_wishlist_returned_item( pub async fn user_wishlist_returned_item(
Path(item_id): Path<uuid::Uuid>, Path(item_id): Path<uuid::Uuid>,
State(db_pool): State<PgPool>, 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 {
sqlx::query("update wishlist_items set purchased_by = null where id = $1") sqlx::query("update wishlist_items set purchased_by = null where id = $1")

View File

@ -1,20 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="row align-items-stretch"> <div class="row align-items-stretch">
<!-- Left panel --> <!-- Left panel -->
<div id="menu" class="col-md-2 bg-light"> <div id="menu" class="col-md-2 bg-light">
<h2>Menu</h2> <h2>Menu</h2>
<ul> <ul>
<li><a href="/dashboard">Web links</a></li> <li><a href="/dashboard">Dashboard</a></li>
<li><a href="/calendar">Calendar</a></li> <li><a href="/calendar">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>Administration</li>
<ul>
<li><a href="/useradmin">User Administration</a></li> <li><a href="/useradmin">User Administration</a></li>
<li><a href="/giftexchanges">Gift Exchanges</a></li> <li><a href="/giftexchanges">Gift Exchanges</a></li>
<li><a href="/newevent">Add Event</a></li>
</ul>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</ul>
</div> </div>
<!-- Center panel --> <!-- Center panel -->
@ -26,6 +30,5 @@
<div id="events" class="col-md-2 bg-light"> <div id="events" class="col-md-2 bg-light">
<h2>Events</h2> <h2>Events</h2>
</div> </div>
</div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -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>

View File

@ -21,6 +21,8 @@
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 %}
@ -30,7 +32,8 @@
</head> </head>
<body> <body>
<div class="container-fluid" height="100%"> <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">
@ -64,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>
@ -72,10 +75,11 @@
<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>

View File

@ -4,34 +4,90 @@
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/daygrid@4.3.0/main.min.css'> <link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/daygrid@4.3.0/main.min.css'>
{% endblock links %} {% endblock links %}
{% block center %} {% block center %}
<h1>Calendar</h1>
<div class="mh-100"> <div class="mh-100">
<div id="calendar" class="fc"></div> <div id="calendar" class="fc"></div>
</div> </div>
<!-- Modal --> <!-- Create Event Modal -->
<div class="modal fade" id="eventDetailsModal" tabindex="-1" role="dialog" aria-labelledby="eventDetailsModalTitle" <div class="modal fade" id="eventDetailsModal" tabindex="-1" role="dialog" aria-labelledby="eventDetailsModalTitle"
aria-hidden="true"> aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document"> <div class="modal-dialog modal-dialog-centered" role="document">
<form id="eventDetailsModalForm">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="eventDetailsModalTitle">Event details</h5> <h5 class="modal-title" id="eventDetailsModalTitle">Request dates</h5>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p id="eventDetailsModalDateRange"></p> <div class="form-group">
<p id="eventDetailsModalBody">Cottage request</p> <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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> <button type="button" id="eventDetailsModalClose" class="btn btn-secondary"
<button type="button" class="btn btn-primary">Save changes</button> data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Send Request</button>
</div> </div>
</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>
</div> </div>
{% endblock center %} {% endblock center %}
{% block scripts %} {% block scripts %}
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js'></script> <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://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 src='https://cdn.jsdelivr.net/npm/uuid@8.3.2/dist/umd/uuidv4.min.js'></script>
<script> <script>
@ -50,29 +106,146 @@
center: 'title', center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,multiMonthYear' right: 'dayGridMonth,timeGridWeek,timeGridDay,multiMonthYear'
}, },
eventSources: [ events: '/calendar/getevents',
{% for calendar in calendars %}
{
url: '/getevents/{{calendar.name}}',
color: '{{calendar.colour}}',
},
{% endfor %}
],
select: function (info) { select: function (info) {
$('#eventStart').val(info.startStr);
$('#eventEnd').val(info.endStr);
$('#eventDetailsModal').modal('show'); $('#eventDetailsModal').modal('show');
$('#eventDetailsModalDateRange').text(info.startStr + ' to ' + info.endStr); },
eventClick: function (info) {
info.view.calendar.addEvent({ if (info.event.title != "In use") {
id: uuidv4(), $('#eventEditTitle').val(info.event.title);
title: 'Cottage request', $('#eventEditStart').val(moment(info.event.start).format('YYYY-MM-DD'));
start: info.startStr + 'T14:00:00', $('#eventEditEnd').val(moment(info.event.end).format('YYYY-MM-DD'));
end: info.endStr + 'T10:00:00', $('#eventEditModal').modal('show');
allDay: false // Store the event for later update
}); window.calEvent = info.event;
}
} }
}); });
calendar.render(); 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> </script>
{% endblock scripts %} {% endblock scripts %}

View File

@ -2,31 +2,54 @@
{% block center %} {% block center %}
<div class="row align-items-stretch"> <div class="row align-items-stretch">
<div class="col-md-6"> <div class="col-md-6">
<h2>Points of Interest</h2> <div class="card">
<a href="https://www.tlccreations.ca" target="_blank" rel="noopener noreferrer"><img title="TLC Creations" <div class="card-body">
src="https://www.tlccreations.ca/assets/images/banner.png" class="img-fluid" alt="TLC Creations"></a> <h5 class="card-title">Points of Interest</h5>
<a href="https://www.seguin.ca" target="_blank" rel="noopener noreferrer"><img title="Seguin" <div class="col-md-6">
src="https://www.seguin.ca/en/images/structure/badge.svg" class="img-fluid" alt="Seguin Township"></a> <a href="https://www.tlccreations.ca" target="_blank" rel="noopener noreferrer"><img
title="TLC Creations" src="https://www.tlccreations.ca/assets/images/banner.png"
class="img-fluid" alt="TLC Creations"></a>
</div>
<div class="col-md-6">
<a href="https://www.seguin.ca" target="_blank" rel="noopener noreferrer"><img
title="Seguin Township" src="https://www.seguin.ca/en/images/structure/badge.svg"
class="img-fluid" alt="Seguin Township"></a>
</div>
</div>
</div>
</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> </div>
<div class="row align-items-stretch" id="firerating"> <div class="row align-items-stretch">
<a href="https://www.seguin.ca/en/explore-play/firerating.aspx" target="_blank" rel="noopener noreferrer"><img <div>
title="Fire Rating: MODERATE" src="https://www.seguin.ca/en/resources/firemodseguin.jpg"
class="img-fluid" alt="Fire Rating: MODERATE"></a>
</div>
<div>
<h2>Web links</h2> <h2>Web links</h2>
<h3>Fonts</h3> <h3>Fonts</h3>
<ul> <ul>
<li><a href="https://fonts.google.com" target="_blank" rel="noopener noreferrer">Google fonts</a></li> <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> <li><a href="https://www.fontspace.com" target="_blank" rel="noopener noreferrer">Font Space</a></li>
</ul> </ul>
<h3>Family tree</h3> <div class="card">
<div class="card-body">
<h5 class="card-title">Family tree</h5>
<ul> <ul>
<li><a href="https://www.ancestry.ca" target="_blank" rel="noopener noreferrer">Ancestry</a></li> <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="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 <li><a href="http://www.tracingroots.ca/" target="_blank" rel="noopener noreferrer">Tracing Roots -
Forth Family
Tree</a></li> Tree</a></li>
</ul> </ul>
</div>
</div>
</div>
</div> </div>
{% endblock center %} {% endblock center %}

View File

@ -0,0 +1,5 @@
User name: {{ user.name }}</p>
Has access: {{ access}}</p>
Database connection: {{ connected }}</p>
Client fingerprint: {{ identity.fingerprint_hash }}</p>

View File

@ -0,0 +1,2 @@
{{ device_status }}</p>
{{ fingerprint_hash }}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block title %}Device Administration{% endblock %}
{% block content %}
<h1>Here are the devices</h1>
{% endblock %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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">

View File

@ -12,7 +12,7 @@
<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 %}

View File

@ -9,7 +9,7 @@
<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 data-toggle="table" class="table table-striped table-bordered"> <table data-toggle="table" class="table table-striped table-bordered">

View File

@ -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'

View File

@ -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'

5
backend/totestviapw10.sh Executable file
View File

@ -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