From 9233c5af15de126fd9f6eb53bdf0f255ab8c257d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Jeaurond?= Date: Fri, 14 Jun 2024 17:16:25 -0400 Subject: [PATCH] feat: create custom 404 pages and create roles + hide/show UI accordingly (#82) * feat: create custom 404 pages * feat: add user count in sidenav * feat: create roles for request/history + hide/show UI related components --- backend/auth/src/lib.rs | 5 +- ...1ac865eb628e6aee419ecb58c8cd8802bcc5.json} | 16 +- backend/database/src/logic/stats.rs | 4 +- backend/database/src/schemas/stats.rs | 4 + backend/makefile | 2 +- backend/server/src/error.rs | 5 +- .../server/src/routes/requests/execute/get.rs | 5 + backend/server/src/routes/requests/get.rs | 18 ++- frontend/src/api/index.ts | 5 + frontend/src/atoms/auth.ts | 2 +- frontend/src/components/auth-link.tsx | 23 +++ frontend/src/components/auth-visible.tsx | 23 +++ frontend/src/components/index.ts | 4 + .../src/components/layouts/authenticated.tsx | 32 ++-- frontend/src/components/nav.tsx | 151 ++++++++++-------- frontend/src/components/not-found.tsx | 19 +++ frontend/src/components/simple-error.tsx | 70 ++++++++ frontend/src/components/stats-counter.tsx | 14 +- frontend/src/i18n/en_CA.json | 9 ++ frontend/src/i18n/fr_CA.json | 9 ++ frontend/src/lib/auth.ts | 30 +++- frontend/src/navigation/index.tsx | 2 + frontend/src/routes/history/$id.tsx | 9 +- frontend/src/routes/history/index.tsx | 20 +-- frontend/src/routes/history/route.tsx | 15 +- frontend/src/routes/index.tsx | 74 ++++++--- frontend/src/routes/lists/$slug.tsx | 11 +- frontend/src/routes/providers/$slug.tsx | 7 + frontend/src/routes/request.tsx | 2 +- frontend/src/routes/sources/$slug.tsx | 7 + frontend/src/routes/users/$id.tsx | 7 + frontend/src/types/backendTypes.ts | 4 + makefile | 4 +- 33 files changed, 473 insertions(+), 139 deletions(-) rename backend/database/.sqlx/{query-f72f903d05b3fff282aeb2b9f1a2281636e5f3168e10d18f8267cc7913ec5738.json => query-f7d8587bafcc9fb8f90457cbc75c1ac865eb628e6aee419ecb58c8cd8802bcc5.json} (77%) create mode 100644 frontend/src/components/auth-link.tsx create mode 100644 frontend/src/components/auth-visible.tsx create mode 100644 frontend/src/components/not-found.tsx create mode 100644 frontend/src/components/simple-error.tsx diff --git a/backend/auth/src/lib.rs b/backend/auth/src/lib.rs index 9541ed2..4c51ae7 100644 --- a/backend/auth/src/lib.rs +++ b/backend/auth/src/lib.rs @@ -11,10 +11,11 @@ use rand::{thread_rng, Rng}; use error::{Error, Result}; -pub fn require_roles(user_roles: &[&str], required_roles: &[&str]) -> Result<()> { +pub fn require_roles(user_roles: &[String], required_roles: &[&str]) -> Result<()> { let missing_roles = required_roles .iter() - .filter(|role| !user_roles.contains(role)) + // TODO: rework this to not need to convert to string + .filter(|role| !user_roles.contains(&role.to_string())) .map(|role| role.to_string()) .collect::>(); diff --git a/backend/database/.sqlx/query-f72f903d05b3fff282aeb2b9f1a2281636e5f3168e10d18f8267cc7913ec5738.json b/backend/database/.sqlx/query-f7d8587bafcc9fb8f90457cbc75c1ac865eb628e6aee419ecb58c8cd8802bcc5.json similarity index 77% rename from backend/database/.sqlx/query-f72f903d05b3fff282aeb2b9f1a2281636e5f3168e10d18f8267cc7913ec5738.json rename to backend/database/.sqlx/query-f7d8587bafcc9fb8f90457cbc75c1ac865eb628e6aee419ecb58c8cd8802bcc5.json index 7189e27..daae874 100644 --- a/backend/database/.sqlx/query-f72f903d05b3fff282aeb2b9f1a2281636e5f3168e10d18f8267cc7913ec5738.json +++ b/backend/database/.sqlx/query-f7d8587bafcc9fb8f90457cbc75c1ac865eb628e6aee419ecb58c8cd8802bcc5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n(SELECT count(*) FROM requests)::int as \"history!\",\n(SELECT count(*)::int FROM requests WHERE created_at > NOW() - INTERVAL '24 hours') as \"history_last_24hrs!\",\n(SELECT count(*) FROM providers)::int as \"providers!\",\n(SELECT count(*)::int FROM providers WHERE enabled)::int as \"enabled_providers!\",\n(SELECT count(*) FROM sources)::int as \"sources!\",\n(SELECT count(*)::int FROM sources WHERE enabled)::int as \"enabled_sources!\",\n(SELECT count(*) FROM ignore_lists)::int as \"ignore_lists!\",\n(SELECT count(*)::int FROM ignore_lists WHERE enabled)::int as \"enabled_ignore_lists!\"\n ", + "query": "SELECT\n(SELECT count(*) FROM requests)::int as \"history!\",\n(SELECT count(*)::int FROM requests WHERE created_at > NOW() - INTERVAL '24 hours') as \"history_last_24hrs!\",\n(SELECT count(*) FROM providers)::int as \"providers!\",\n(SELECT count(*)::int FROM providers WHERE enabled)::int as \"enabled_providers!\",\n(SELECT count(*) FROM sources)::int as \"sources!\",\n(SELECT count(*)::int FROM sources WHERE enabled)::int as \"enabled_sources!\",\n(SELECT count(*) FROM ignore_lists)::int as \"ignore_lists!\",\n(SELECT count(*)::int FROM ignore_lists WHERE enabled)::int as \"enabled_ignore_lists!\",\n(SELECT count(*) FROM users)::int as \"users!\",\n(SELECT count(*)::int FROM users WHERE enabled)::int as \"enabled_users!\"\n ", "describe": { "columns": [ { @@ -42,6 +42,16 @@ "ordinal": 7, "name": "enabled_ignore_lists!", "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "users!", + "type_info": "Int4" + }, + { + "ordinal": 9, + "name": "enabled_users!", + "type_info": "Int4" } ], "parameters": { @@ -55,8 +65,10 @@ null, null, null, + null, + null, null ] }, - "hash": "f72f903d05b3fff282aeb2b9f1a2281636e5f3168e10d18f8267cc7913ec5738" + "hash": "f7d8587bafcc9fb8f90457cbc75c1ac865eb628e6aee419ecb58c8cd8802bcc5" } diff --git a/backend/database/src/logic/stats.rs b/backend/database/src/logic/stats.rs index fd800c1..0e005fc 100644 --- a/backend/database/src/logic/stats.rs +++ b/backend/database/src/logic/stats.rs @@ -15,7 +15,9 @@ pub async fn count(pool: &PgPool) -> Result { (SELECT count(*) FROM sources)::int as "sources!", (SELECT count(*)::int FROM sources WHERE enabled)::int as "enabled_sources!", (SELECT count(*) FROM ignore_lists)::int as "ignore_lists!", -(SELECT count(*)::int FROM ignore_lists WHERE enabled)::int as "enabled_ignore_lists!" +(SELECT count(*)::int FROM ignore_lists WHERE enabled)::int as "enabled_ignore_lists!", +(SELECT count(*) FROM users)::int as "users!", +(SELECT count(*)::int FROM users WHERE enabled)::int as "enabled_users!" "# ) .fetch_one(pool) diff --git a/backend/database/src/schemas/stats.rs b/backend/database/src/schemas/stats.rs index 793b798..8614463 100644 --- a/backend/database/src/schemas/stats.rs +++ b/backend/database/src/schemas/stats.rs @@ -25,6 +25,10 @@ pub struct Count { pub ignore_lists: i32, /// Number of enabled indicators pub enabled_ignore_lists: i32, + /// Number of users + pub users: i32, + /// Number of enabled users + pub enabled_users: i32, } /// A stats helper container for getting a count based on an ID or name of an object diff --git a/backend/makefile b/backend/makefile index 6044fa3..1408e11 100644 --- a/backend/makefile +++ b/backend/makefile @@ -1,4 +1,4 @@ -reset-db: +db-reset: ./scripts/reset_db.sh && make gen-types gen-types: diff --git a/backend/server/src/error.rs b/backend/server/src/error.rs index 21bd24f..7ae537e 100644 --- a/backend/server/src/error.rs +++ b/backend/server/src/error.rs @@ -38,6 +38,7 @@ pub enum Error { PasswordHash(shared::crypto::PasswordHashError), InvalidCredentials, DisabledUser, + Forbidden, } impl std::error::Error for Error {} @@ -110,7 +111,7 @@ impl From for Error { match error { auth::error::Error::Database(err) => Self::SqlxError(err), auth::error::Error::Unauthorized(_) => Self::Unauthorized, - auth::error::Error::MissingRoles(_) => Self::Unauthorized, + auth::error::Error::MissingRoles(_) => Self::Forbidden, auth::error::Error::BadRequest(err) => Self::BadRequest(err), auth::error::Error::SerdeJson(_) => Self::InternalError, auth::error::Error::Reqwest(err) => Self::Reqwest(err), @@ -150,6 +151,8 @@ impl IntoResponse for Error { Self::DisabledUser => { (StatusCode::UNAUTHORIZED, "Your account is disabled").into_response() } + Self::NotFound => StatusCode::NOT_FOUND.into_response(), + Self::Forbidden => StatusCode::FORBIDDEN.into_response(), _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } diff --git a/backend/server/src/routes/requests/execute/get.rs b/backend/server/src/routes/requests/execute/get.rs index 9f4b180..64334d8 100644 --- a/backend/server/src/routes/requests/execute/get.rs +++ b/backend/server/src/routes/requests/execute/get.rs @@ -1,3 +1,4 @@ +use auth::require_roles; use axum::{ extract::State, response::{ @@ -34,6 +35,8 @@ pub async fn request( Extension(user): Extension, Query(request): Query, ) -> Result { + require_roles(&user.roles, &["request_create"])?; + let should_ignore_errors = request.ignore_errors; let mut data = handle_indicator_request(&request, &state, &user.id).await?; @@ -87,6 +90,8 @@ pub async fn sse_handler( Extension(user): Extension, Query(request): Query, ) -> Result { + require_roles(&user.roles, &["request_create"])?; + let should_ignore_errors = request.ignore_errors; let source_ids = request.source_ids.clone(); let indicator: Indicator = request.into(); diff --git a/backend/server/src/routes/requests/get.rs b/backend/server/src/routes/requests/get.rs index afc0897..e6a6001 100644 --- a/backend/server/src/routes/requests/get.rs +++ b/backend/server/src/routes/requests/get.rs @@ -1,9 +1,10 @@ +use auth::require_roles; use axum::{ extract::{Path, State}, response::IntoResponse, - Json, + Extension, Json, }; -use database::{logic::requests, PgPool}; +use database::{logic::requests, schemas::users::User, PgPool}; use crate::{Error, Result}; @@ -16,7 +17,12 @@ use crate::{Error, Result}; (status = 200, description = "List of requests", body = [Request]), ) )] -pub async fn get_requests(State(pool): State) -> Result { +pub async fn get_requests( + Extension(user): Extension, + State(pool): State, +) -> Result { + require_roles(&user.roles, &["request_view"])?; + let requests = requests::get_requests(&pool).await?; Ok(Json(requests)) @@ -36,9 +42,12 @@ pub async fn get_requests(State(pool): State) -> Result, State(pool): State, Path(request_id): Path, ) -> Result { + require_roles(&user.roles, &["request_view"])?; + let request = requests::get_request(&pool, &request_id) .await? .ok_or(Error::NotFound)?; @@ -59,9 +68,12 @@ pub async fn get_request( ) )] pub async fn get_request_data( + Extension(user): Extension, State(pool): State, Path(request_id): Path, ) -> Result { + require_roles(&user.roles, &["request_view"])?; + let request_data = requests::get_request_source_requests(&pool, &request_id).await?; Ok(Json(request_data)) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 19bd615..bee41d3 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,4 +1,5 @@ import { toast } from "sonner"; +import { notFound } from "@tanstack/react-router"; import config from "@/lib/config"; import { store } from "@/atoms"; @@ -53,6 +54,10 @@ const axiosKiller = async ( keepalive: true, }); + if (response.status === 404) { + throw notFound(); + } + if (!response.ok) { const text = await response.text(); diff --git a/frontend/src/atoms/auth.ts b/frontend/src/atoms/auth.ts index f101843..8e6bbc5 100644 --- a/frontend/src/atoms/auth.ts +++ b/frontend/src/atoms/auth.ts @@ -1,6 +1,6 @@ import { atomWithLocalStorage } from "@/atoms"; -interface User { +export interface User { id: string; email: string; name: string; diff --git a/frontend/src/components/auth-link.tsx b/frontend/src/components/auth-link.tsx new file mode 100644 index 0000000..2d2b5ab --- /dev/null +++ b/frontend/src/components/auth-link.tsx @@ -0,0 +1,23 @@ +import { Link, LinkProps } from "@tanstack/react-router"; +import { useAtomValue } from "jotai"; + +import { userAtom } from "@/atoms/auth"; +import { userHasRoles } from "@/lib/auth"; + +interface Props + extends LinkProps, + Pick, "title"> { + roles?: string[]; +} + +const AuthLink: React.FC = ({ roles = [], ...props }) => { + const user = useAtomValue(userAtom); + + if (user && !userHasRoles(user, roles)) { + return <>{props.children}; + } + + return ; +}; + +export default AuthLink; diff --git a/frontend/src/components/auth-visible.tsx b/frontend/src/components/auth-visible.tsx new file mode 100644 index 0000000..c0b0e82 --- /dev/null +++ b/frontend/src/components/auth-visible.tsx @@ -0,0 +1,23 @@ +import { useAtomValue } from "jotai"; + +import { userAtom } from "@/atoms/auth"; +import { userHasRoles } from "@/lib/auth"; + +interface Props { + roles: string[]; +} + +const AuthVisible: React.FC> = ({ + roles, + children, +}) => { + const user = useAtomValue(userAtom); + + if (user && !userHasRoles(user, roles)) { + return null; + } + + return children; +}; + +export default AuthVisible; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 3a7e205..ec2c2f5 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -4,6 +4,8 @@ export * as Layouts from "./layouts"; export * as SearchResults from "./search-results"; export { default as ApiTokensTable } from "./api-tokens-table"; +export { default as AuthLink } from "./auth-link"; +export { default as AuthVisible } from "./auth-visible"; export { default as AutoAnimate } from "./auto-animate"; export { default as Code } from "./code"; export { default as CopyButton } from "./copy-button"; @@ -18,11 +20,13 @@ export { default as GeneralServerConfig } from "./general-server-config"; export { default as GenericPanelSearch } from "./generic-panel-search"; export { default as MaskValue } from "./mask-value"; export { default as Nav } from "./nav"; +export { default as NotFound } from "./not-found"; export { default as RawContent } from "./raw-content"; export { default as RequestDataView } from "./request-data-view"; export { default as RunnerStatus } from "./runner-status"; export { default as SecretsTable } from "./secrets-table"; export { default as SectionPanelHeader } from "./section-panel-header"; +export { default as SimpleError } from "./simple-error"; export { default as StackedAreaChart } from "./stacked-area-chart"; export { default as StatsCounter } from "./stats-counter"; export { default as TitleEntryCount } from "./title-entry-count"; diff --git a/frontend/src/components/layouts/authenticated.tsx b/frontend/src/components/layouts/authenticated.tsx index b33d5bc..84c8290 100644 --- a/frontend/src/components/layouts/authenticated.tsx +++ b/frontend/src/components/layouts/authenticated.tsx @@ -44,6 +44,7 @@ import config from "@/lib/config"; import { useWindowWidth } from "@/hooks/useWindowWidth"; import { userAtom } from "@/atoms/auth"; import { useTranslation } from "@/i18n"; +import { userHasAnyRoles } from "@/lib/auth"; type Page = | "home" @@ -61,7 +62,7 @@ export const Layout: React.FC = () => { const { location } = useRouterState(); const [theme, setTheme] = useAtom(themeAtom); const windowWidth = useWindowWidth(); - const auth = useAtomValue(userAtom); + const user = useAtomValue(userAtom); const { t, toggle, otherLang } = useTranslation(); const statsCount = useSuspenseQuery(statsCountQueryOptions); @@ -125,6 +126,7 @@ export const Layout: React.FC = () => { isCollapsed={isCollapsed} links={[ { + roles: ["request_create"], title: t("requests"), icon: Send, to: "/request", @@ -132,6 +134,7 @@ export const Layout: React.FC = () => { preload: false, }, { + roles: ["request_view"], title: t("history"), label: statsCount.data.history, icon: History, @@ -140,9 +143,11 @@ export const Layout: React.FC = () => { }, ]} /> -
- -
+ {userHasAnyRoles(user!, ["request_create", "request_view"]) && ( +
+ +
+ )}