diff --git a/engine/src/routes/mod.rs b/engine/src/routes/mod.rs index 3dbcf29..ca4a65b 100644 --- a/engine/src/routes/mod.rs +++ b/engine/src/routes/mod.rs @@ -12,6 +12,7 @@ use crate::state::AppState; pub mod me; pub mod oauth; +pub mod properties; pub mod root; pub mod sessions; diff --git a/engine/src/routes/properties/mod.rs b/engine/src/routes/properties/mod.rs new file mode 100644 index 0000000..17d2025 --- /dev/null +++ b/engine/src/routes/properties/mod.rs @@ -0,0 +1,29 @@ +use std::sync::Arc; + +use poem::web::Data; +use poem_openapi::{payload::Json, OpenApi}; + +use crate::{ + auth::middleware::AuthToken, models::property::Property, state::AppState +}; + +pub struct ApiProperties; + +#[OpenApi] +impl ApiProperties { + #[oai(path = "/property/:property_id", method = "get")] + async fn get_property( + &self, + auth: AuthToken, + state: Data<&Arc>, + ) -> poem_openapi::payload::Json> { + match auth { + AuthToken::Active(active_user) => poem_openapi::payload::Json( + Property::get_by_owner_id(active_user.session.user_id, &state.database) + .await + .unwrap(), + ), + AuthToken::None => poem_openapi::payload::Json(vec![]), + } + } +} diff --git a/web/package.json b/web/package.json index 562c21a..753e786 100644 --- a/web/package.json +++ b/web/package.json @@ -21,9 +21,11 @@ "clsx": "^2.1.1", "eslint-plugin-unused-imports": "^4.0.1", "globals": "^15.8.0", + "leaflet": "^1.9.4", "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "swr": "^2.2.5", "tailwindcss": "^3.4.3", "ua-parser-js": "^1.0.38", @@ -33,6 +35,7 @@ "devDependencies": { "@tanstack/router-devtools": "^1.45.14", "@tanstack/router-plugin": "^1.45.13", + "@types/leaflet": "^1.9.12", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/ua-parser-js": "^0.7.39", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a9e68b2..2a0d21c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: globals: specifier: ^15.8.0 version: 15.8.0 + leaflet: + specifier: ^1.9.4 + version: 1.9.4 postcss: specifier: ^8.4.38 version: 8.4.38 @@ -38,6 +41,9 @@ dependencies: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-leaflet: + specifier: ^4.2.1 + version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1)(react@18.3.1) swr: specifier: ^2.2.5 version: 2.2.5(react@18.3.1) @@ -61,6 +67,9 @@ devDependencies: '@tanstack/router-plugin': specifier: ^1.45.13 version: 1.45.13(vite@5.2.10) + '@types/leaflet': + specifier: ^1.9.12 + version: 1.9.12 '@types/react': specifier: ^18.3.3 version: 18.3.3 @@ -613,6 +622,18 @@ packages: dev: false optional: true + /@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==} + peerDependencies: + leaflet: ^1.9.0 + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + leaflet: 1.9.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@rollup/rollup-android-arm-eabi@4.16.4: resolution: {integrity: sha512-GkhjAaQ8oUTOKE4g4gsZ0u8K/IHU1+2WQSgS1TwTcYvL+sjbaQjNHFXbOJ6kgqGHIO1DfUhI/Sphi9GkRT9K+Q==} cpu: [arm] @@ -847,6 +868,16 @@ packages: /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + /@types/geojson@7946.0.14: + resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} + dev: true + + /@types/leaflet@1.9.12: + resolution: {integrity: sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg==} + dependencies: + '@types/geojson': 7946.0.14 + dev: true + /@types/normalize-package-data@2.4.4: resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} dev: true @@ -2527,6 +2558,10 @@ packages: language-subtag-registry: 0.3.23 dev: true + /leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + dev: false + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2941,6 +2976,19 @@ packages: react: 18.3.1 scheduler: 0.23.2 + /react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==} + peerDependencies: + leaflet: ^1.9.0 + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.3.1)(react@18.3.1) + leaflet: 1.9.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} diff --git a/web/src/api/geoip.ts b/web/src/api/geoip.ts new file mode 100644 index 0000000..0f4566c --- /dev/null +++ b/web/src/api/geoip.ts @@ -0,0 +1,30 @@ +import useSWR from 'swr'; + +type GeoIpResponse = { + ip_address: string; + latitude: number; + longitude: number; + postal_code: string; + continent_code: string; + continent_name: string; + country_code: string; + country_name: string; + region_code: string; + region_name: string; + province_code: string; + province_name: string; + city_name: string; + timezone: string; +}; + +export const useGeoIp = (ip: string) => + useSWR( + 'geo:' + ip, + async () => { + const response = await fetch('https://api.geoip.rs/?ip=' + ip); + const data = await response.json(); + + return data as GeoIpResponse; + }, + { errorRetryInterval: 10_000 } + ); diff --git a/web/src/api/media.ts b/web/src/api/media.ts new file mode 100644 index 0000000..fca3c1d --- /dev/null +++ b/web/src/api/media.ts @@ -0,0 +1,10 @@ +import { useHttp } from './core'; + +type MediaResponse = { + id: number; + description: string; + url: string; +}; + +export const useMedia = (id: string) => + useHttp('/api/media/' + id); diff --git a/web/src/api/property.ts b/web/src/api/property.ts new file mode 100644 index 0000000..4610f36 --- /dev/null +++ b/web/src/api/property.ts @@ -0,0 +1,14 @@ +import { useHttp } from './core'; + +export type PropertyResponse = { + id: number; + owner_id: number; + product_id: number; + name?: string; + media?: number[]; + created?: string; + modified?: string; +}; + +export const useProperty = (id: string) => + useHttp('/api/property/' + id); diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts index 0f51639..c67125f 100644 --- a/web/src/api/sessions.ts +++ b/web/src/api/sessions.ts @@ -1,6 +1,6 @@ import { useHttp } from './core'; -type SessionResponse = { +export type SessionResponse = { id: string; user_id: number; user_agent: string; diff --git a/web/src/components/ActiveSessionsTable.tsx b/web/src/components/ActiveSessionsTable.tsx index 15bf68f..d6e28b7 100644 --- a/web/src/components/ActiveSessionsTable.tsx +++ b/web/src/components/ActiveSessionsTable.tsx @@ -3,8 +3,106 @@ import { FC } from 'react'; import { UAParser } from 'ua-parser-js'; import { useAuth } from '../api/auth'; -import { useSessions } from '../api/sessions'; +import { useGeoIp } from '../api/geoip'; +import { SessionResponse, useSessions } from '../api/sessions'; import { getRelativeTimeString } from '../util/date'; +import { LeafletPreview } from './LeafletPreview'; + +const ActiveSession: FC<{ session: SessionResponse }> = ({ session }) => { + const { data: sessions, mutate: updateSessions } = useSessions(); + const { token } = useAuth(); + const { data: geoip } = useGeoIp('77.162.232.110' /* session.user_ip */); + const user_agent = UAParser(session.user_agent); + const last_accessed = new Date(session.last_access); + const last_accessed_formatted = getRelativeTimeString(last_accessed); + const isRecent = last_accessed.getTime() > Date.now() - 1000 * 60 * 60 * 24; + + const latitude = geoip?.latitude || 0; + const longitude = geoip?.longitude || 0; + + return ( +
+ {geoip?.latitude && ( +
+
+ {/*