From 4c4b82c801a35825524b355b06d022cfd871bc48 Mon Sep 17 00:00:00 2001 From: Samuel Shuert Date: Wed, 1 Jul 2026 00:41:27 +0000 Subject: [PATCH] fix: get permissions working --- backend/Cargo.lock | 23 +++++++ backend/Cargo.toml | 2 +- .../20260701005834_u64_permissions.down.sql | 3 + .../20260701005834_u64_permissions.up.sql | 3 + backend/src/models/user.rs | 62 ++++++++++++----- backend/src/routes/auth.rs | 4 +- backend/src/routes/user.rs | 69 ++++++++++++++++++- frontend/components/dashboard.tsx | 63 ++++++++++++++--- frontend/lib/session.ts | 7 +- frontend/lib/userPermissions.ts | 24 ++++++- 10 files changed, 224 insertions(+), 36 deletions(-) create mode 100644 backend/migrations/20260701005834_u64_permissions.down.sql create mode 100644 backend/migrations/20260701005834_u64_permissions.up.sql diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c00775a..1c4fad1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -400,6 +400,19 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "bindgen" version = "0.59.2" @@ -1557,6 +1570,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.37.0" @@ -2464,6 +2483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64 0.22.1", + "bigdecimal", "bytes", "cfg-if", "chrono", @@ -2538,6 +2558,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ + "bigdecimal", "bitflags 2.13.0", "byteorder", "bytes", @@ -2568,6 +2589,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", + "bigdecimal", "bitflags 2.13.0", "byteorder", "chrono", @@ -2584,6 +2606,7 @@ dependencies = [ "log", "md-5", "memchr", + "num-bigint", "rand 0.10.1", "serde", "serde_json", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d106765..b4e1f01 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -20,7 +20,7 @@ perf_monitor = "0.2.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.11.0" -sqlx = { version = "0.9.0", features = ["postgres", "uuid", "chrono", "migrate", "runtime-tokio"] } +sqlx = { version = "0.9.0", features = ["postgres", "uuid", "chrono", "migrate", "runtime-tokio", "bigdecimal"] } tokio = { version = "1.52.3", features = ["full"] } tracing = "0.1.44" tracing-actix-web = "0.7.21" diff --git a/backend/migrations/20260701005834_u64_permissions.down.sql b/backend/migrations/20260701005834_u64_permissions.down.sql new file mode 100644 index 0000000..e30ca11 --- /dev/null +++ b/backend/migrations/20260701005834_u64_permissions.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +UPDATE users SET permissions = 9223372036854775807 WHERE permissions = 18446744073709551615; +ALTER TABLE users ALTER COLUMN permissions TYPE BIGINT USING permissions::BIGINT; diff --git a/backend/migrations/20260701005834_u64_permissions.up.sql b/backend/migrations/20260701005834_u64_permissions.up.sql new file mode 100644 index 0000000..f56da5e --- /dev/null +++ b/backend/migrations/20260701005834_u64_permissions.up.sql @@ -0,0 +1,3 @@ +-- Add up migration script here +ALTER TABLE users ALTER COLUMN permissions TYPE NUMERIC(20) USING permissions::NUMERIC(20); +UPDATE users SET permissions = 18446744073709551615 WHERE permissions = 9223372036854775807; diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index ada64a3..459ede2 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -4,11 +4,18 @@ use argon2::{ Argon2, PasswordHash, PasswordHasher as _, PasswordVerifier as _, password_hash::{SaltString, rand_core::OsRng}, }; -use serde::{Deserialize, Serialize}; -use sqlx::PgConnection; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sqlx::{ + Decode, Encode, PgConnection, Postgres, Type, + encode::IsNull, + error::BoxDynError, + postgres::{PgArgumentBuffer, PgTypeInfo, PgValueRef}, + prelude::FromRow, + types::BigDecimal, +}; use uuid::Uuid; -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, FromRow)] pub struct User { pub id: Uuid, pub full_name: String, @@ -129,7 +136,7 @@ impl CrudBackend for User { self.full_name, self.username, self.password, - i64::from(self.permissions), + BigDecimal::from(self.permissions.bits()), self.id ) .fetch_one(pool) @@ -261,30 +268,51 @@ impl std::fmt::Display for Permissions { } impl Serialize for Permissions { - fn serialize(&self, serializer: S) -> Result { - if serializer.is_human_readable() { - serializer.serialize_str(&self.to_string()) - } else { - serializer.serialize_u64(self.bits()) - } + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.bits().to_string()) } } impl<'de> Deserialize<'de> for Permissions { - fn deserialize>(deserializer: D) -> Result { - let bits = u64::deserialize(deserializer)?; + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + let bits: u64 = s.parse().map_err(serde::de::Error::custom)?; Ok(Permissions::from_bits_truncate(bits)) } } -impl From for Permissions { - fn from(bits: i64) -> Self { - Permissions::from_bits_truncate(bits as u64) +impl Type for Permissions { + fn type_info() -> PgTypeInfo { + PgTypeInfo::with_name("NUMERIC") + } +} + +impl<'r> Decode<'r, Postgres> for Permissions { + fn decode(value: PgValueRef<'r>) -> Result { + let bd = >::decode(value)?; + Ok(Permissions::from(bd)) + } +} + +impl<'q> Encode<'q, Postgres> for Permissions { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result { + let bd: BigDecimal = (*self).into(); + bd.encode_by_ref(buf) + } +} + +impl From for Permissions { + fn from(value: BigDecimal) -> Self { + let bits = value + .to_string() + .parse::() + .expect("permissions value out of u64 range"); + Permissions::from_bits_truncate(bits) } } -impl From for i64 { +impl From for BigDecimal { fn from(perms: Permissions) -> Self { - perms.bits() as i64 + perms.bits().to_string().parse::().unwrap() } } diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 84620b9..a0587cb 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -37,7 +37,9 @@ pub async fn get_self( } }; - Ok(HttpResponse::Ok().json(u)) + println!("{:?}", u); + + Ok(HttpResponse::Ok().json(u.without_password())) } #[derive(Debug, Deserialize, Serialize)] diff --git a/backend/src/routes/user.rs b/backend/src/routes/user.rs index 23f0d1e..38910ef 100644 --- a/backend/src/routes/user.rs +++ b/backend/src/routes/user.rs @@ -1,13 +1,27 @@ use crate::models::{ - Crud as _, + Crud, CrudBackend, user::{User, UserCreate, UserIdentifier, UserUpdate}, }; +use crate::routes::SortOrder; use actix_identity::Identity; -use actix_web::{HttpResponse, Responder, Scope}; +use actix_web::{ + HttpResponse, Responder, Scope, + web::{Data, Query}, +}; +use serde::Deserialize; use sqlx::PgPool; const ROUTE: &'static str = "/users"; +#[derive(Debug, Clone, Deserialize)] +struct QueryParams { + pub limit: Option, + pub offset: Option, + pub sort_order: Option, + pub username: Option, + pub full_name: Option, +} + #[actix_web::post("")] async fn post( pool: actix_web::web::Data, @@ -39,6 +53,56 @@ async fn get( } } +#[actix_web::get("")] +async fn get_all(pool: Data, options: Query, user: User) -> impl Responder { + if !user.permissions.contains(User::PERM_READ) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to get {}", + User::TABLE_NAME + )); + } + + let limit = options.limit.unwrap_or(100) as i64; + let offset = options.offset.unwrap_or(0) as i64; + let sort_asc = options.sort_order.unwrap_or(SortOrder::Desc) == SortOrder::Asc; + + let mut query = sqlx::QueryBuilder::new(format!("SELECT * FROM {}", User::TABLE_NAME)); + query.push(" WHERE 1=1"); + + if let Some(username) = &options.username { + query.push(" AND username ILIKE "); + query.push_bind(format!("%{}%", username)); + } + if let Some(full_name) = &options.full_name { + query.push(" AND full_name ILIKE "); + query.push_bind(format!("%{}%", full_name)); + } + + query.push(" ORDER BY full_name "); + if sort_asc { + query.push("ASC"); + } else { + query.push("DESC"); + } + + query.push(" LIMIT "); + query.push_bind(limit); + query.push(" OFFSET "); + query.push_bind(offset); + + match query + .build_query_as::() + .fetch_all(pool.get_ref()) + .await + { + Ok(batches) => HttpResponse::Ok().json(batches), + Err(err) => { + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } +} + #[actix_web::patch("/{user_id}")] async fn patch( pool: actix_web::web::Data, @@ -98,4 +162,5 @@ pub fn service() -> Scope { .service(get) .service(patch) .service(delete) + .service(get_all) } diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 4956445..ed7e8cc 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { User } from "@/lib/session" +import { UserPermissions } from "@/lib/userPermissions" import { Sidebar, @@ -23,7 +24,6 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Separator } from "@/components/ui/separator" import { Button } from "@/components/ui/button" import { - LayoutDashboard, Users, Candy, FlaskConical, @@ -51,14 +51,54 @@ type Page = | "suppliers" | "users" -const NAV_ITEMS: { label: string; page: Page; icon: React.ElementType }[] = [ - { label: "Batches", page: "batches", icon: Candy }, - { label: "Customers", page: "customers", icon: Users }, - { label: "Flavors", page: "flavors", icon: ChefHat }, - { label: "Ingredients", page: "ingredients", icon: FlaskConical }, - { label: "Orders", page: "orders", icon: ShoppingCart }, - { label: "Suppliers", page: "suppliers", icon: Truck }, - { label: "Users", page: "users", icon: UserCog }, +const NAV_ITEMS: { + label: string + page: Page + icon: React.ElementType + read_permission: UserPermissions +}[] = [ + { + label: "Batches", + page: "batches", + icon: Candy, + read_permission: UserPermissions.BatchRead, + }, + { + label: "Customers", + page: "customers", + icon: Users, + read_permission: UserPermissions.CustomerRead, + }, + { + label: "Flavors", + page: "flavors", + icon: ChefHat, + read_permission: UserPermissions.FlavorRead, + }, + { + label: "Ingredients", + page: "ingredients", + icon: FlaskConical, + read_permission: UserPermissions.IngredientRead, + }, + { + label: "Orders", + page: "orders", + icon: ShoppingCart, + read_permission: UserPermissions.OrderRead, + }, + { + label: "Suppliers", + page: "suppliers", + icon: Truck, + read_permission: UserPermissions.SupplierRead, + }, + { + label: "Users", + page: "users", + icon: UserCog, + read_permission: UserPermissions.UserRead, + }, ] const PAGE_MAP: Record = { @@ -79,6 +119,7 @@ export function Dashboard({ user }: DashboardProps) { const [activePage, setActivePage] = useState("batches") const router = useRouter() + const permissions = UserPermissions.from(user.permissions) const initials = user.full_name ? user.full_name .split(" ") @@ -110,7 +151,9 @@ export function Dashboard({ user }: DashboardProps) { Management - {NAV_ITEMS.map(({ label, page, icon: Icon }) => ( + {NAV_ITEMS.filter(({ read_permission }) => + permissions.contains(read_permission) + ).map(({ label, page, icon: Icon }) => ( { @@ -32,8 +32,9 @@ export async function getSession(): Promise { if (!res.ok) return null const res_data: UserResponse = await res.json() + return { ...res_data, - permissions: UserPermissions.fromBits(res_data.permissions), + permissions: UserPermissions.fromBits(res_data.permissions).toBitsString(), } } diff --git a/frontend/lib/userPermissions.ts b/frontend/lib/userPermissions.ts index 24559c4..35930ec 100644 --- a/frontend/lib/userPermissions.ts +++ b/frontend/lib/userPermissions.ts @@ -115,8 +115,28 @@ export class UserPermissions { static readonly SuperAdmin = UserPermissions.of((1n << 64n) - 1n) static readonly Empty = UserPermissions.of(0n) - static fromBits(bits: bigint): UserPermissions { - return UserPermissions.of(bits & UserPermissions.SuperAdmin.bits) + static fromBits(bits: bigint | string | number): UserPermissions { + const b = typeof bits === "bigint" ? bits : BigInt(bits) + return UserPermissions.of(b & UserPermissions.SuperAdmin.bits) + } + + toBitsString(): string { + return this.bits.toString() + } + + static from( + value: + | UserPermissions + | { bits: bigint | number | string } + | bigint + | number + | string + ): UserPermissions { + if (value instanceof UserPermissions) return value + if (typeof value === "object" && value !== null && "bits" in value) { + return UserPermissions.fromBits(value.bits) + } + return UserPermissions.fromBits(value) } union(...others: UserPermissions[]): UserPermissions { -- 2.51.2