Skip to content

Commit

Permalink
feat: create custom 404 pages and create roles + hide/show UI accordi…
Browse files Browse the repository at this point in the history
…ngly (#82)

* feat: create custom 404 pages

* feat: add user count in sidenav

* feat: create roles for request/history + hide/show UI related components
  • Loading branch information
BenJeau committed Jun 14, 2024
1 parent 1ba439d commit 9233c5a
Show file tree
Hide file tree
Showing 33 changed files with 473 additions and 139 deletions.
5 changes: 3 additions & 2 deletions backend/auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion backend/database/src/logic/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ pub async fn count(pool: &PgPool) -> Result<Count> {
(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)
Expand Down
4 changes: 4 additions & 0 deletions backend/database/src/schemas/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
reset-db:
db-reset:
./scripts/reset_db.sh && make gen-types

gen-types:
Expand Down
5 changes: 4 additions & 1 deletion backend/server/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub enum Error {
PasswordHash(shared::crypto::PasswordHashError),
InvalidCredentials,
DisabledUser,
Forbidden,
}

impl std::error::Error for Error {}
Expand Down Expand Up @@ -110,7 +111,7 @@ impl From<auth::error::Error> 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),
Expand Down Expand Up @@ -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(),
}
}
Expand Down
5 changes: 5 additions & 0 deletions backend/server/src/routes/requests/execute/get.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use auth::require_roles;
use axum::{
extract::State,
response::{
Expand Down Expand Up @@ -34,6 +35,8 @@ pub async fn request(
Extension(user): Extension<User>,
Query(request): Query<RequestExecuteParam>,
) -> Result<impl IntoResponse> {
require_roles(&user.roles, &["request_create"])?;

let should_ignore_errors = request.ignore_errors;

let mut data = handle_indicator_request(&request, &state, &user.id).await?;
Expand Down Expand Up @@ -87,6 +90,8 @@ pub async fn sse_handler(
Extension(user): Extension<User>,
Query(request): Query<RequestExecuteParam>,
) -> Result<impl IntoResponse> {
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();
Expand Down
18 changes: 15 additions & 3 deletions backend/server/src/routes/requests/get.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -16,7 +17,12 @@ use crate::{Error, Result};
(status = 200, description = "List of requests", body = [Request]),
)
)]
pub async fn get_requests(State(pool): State<PgPool>) -> Result<impl IntoResponse> {
pub async fn get_requests(
Extension(user): Extension<User>,
State(pool): State<PgPool>,
) -> Result<impl IntoResponse> {
require_roles(&user.roles, &["request_view"])?;

let requests = requests::get_requests(&pool).await?;

Ok(Json(requests))
Expand All @@ -36,9 +42,12 @@ pub async fn get_requests(State(pool): State<PgPool>) -> Result<impl IntoRespons
)
)]
pub async fn get_request(
Extension(user): Extension<User>,
State(pool): State<PgPool>,
Path(request_id): Path<String>,
) -> Result<impl IntoResponse> {
require_roles(&user.roles, &["request_view"])?;

let request = requests::get_request(&pool, &request_id)
.await?
.ok_or(Error::NotFound)?;
Expand All @@ -59,9 +68,12 @@ pub async fn get_request(
)
)]
pub async fn get_request_data(
Extension(user): Extension<User>,
State(pool): State<PgPool>,
Path(request_id): Path<String>,
) -> Result<impl IntoResponse> {
require_roles(&user.roles, &["request_view"])?;

let request_data = requests::get_request_source_requests(&pool, &request_id).await?;

Ok(Json(request_data))
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { toast } from "sonner";
import { notFound } from "@tanstack/react-router";

import config from "@/lib/config";
import { store } from "@/atoms";
Expand Down Expand Up @@ -53,6 +54,10 @@ const axiosKiller = async <T>(
keepalive: true,
});

if (response.status === 404) {
throw notFound();
}

if (!response.ok) {
const text = await response.text();

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/atoms/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { atomWithLocalStorage } from "@/atoms";

interface User {
export interface User {
id: string;
email: string;
name: string;
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/components/auth-link.tsx
Original file line number Diff line number Diff line change
@@ -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<React.HTMLAttributes<HTMLAnchorElement>, "title"> {
roles?: string[];
}

const AuthLink: React.FC<Props> = ({ roles = [], ...props }) => {
const user = useAtomValue(userAtom);

if (user && !userHasRoles(user, roles)) {
return <>{props.children}</>;
}

return <Link {...props} />;
};

export default AuthLink;
23 changes: 23 additions & 0 deletions frontend/src/components/auth-visible.tsx
Original file line number Diff line number Diff line change
@@ -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<React.PropsWithChildren<Props>> = ({
roles,
children,
}) => {
const user = useAtomValue(userAtom);

if (user && !userHasRoles(user, roles)) {
return null;
}

return children;
};

export default AuthVisible;
4 changes: 4 additions & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down
32 changes: 19 additions & 13 deletions frontend/src/components/layouts/authenticated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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);
Expand Down Expand Up @@ -125,13 +126,15 @@ export const Layout: React.FC = () => {
isCollapsed={isCollapsed}
links={[
{
roles: ["request_create"],
title: t("requests"),
icon: Send,
to: "/request",
variant: page === "requests" ? "default" : "ghost",
preload: false,
},
{
roles: ["request_view"],
title: t("history"),
label: statsCount.data.history,
icon: History,
Expand All @@ -140,9 +143,11 @@ export const Layout: React.FC = () => {
},
]}
/>
<div className={cn("mx-4", isCollapsed && "mx-2")}>
<Separator />
</div>
{userHasAnyRoles(user!, ["request_create", "request_view"]) && (
<div className={cn("mx-4", isCollapsed && "mx-2")}>
<Separator />
</div>
)}
<Nav
isCollapsed={isCollapsed}
links={[
Expand Down Expand Up @@ -175,18 +180,19 @@ export const Layout: React.FC = () => {
<Nav
isCollapsed={isCollapsed}
links={[
{
title: t("config"),
to: "/config",
variant: page === "config" ? "default" : "ghost",
icon: Cog,
},
{
title: t("users"),
label: statsCount.data.users,
to: "/users",
variant: page === "users" ? "default" : "ghost",
icon: Users,
},
{
title: t("config"),
to: "/config",
variant: page === "config" ? "default" : "ghost",
icon: Cog,
},
]}
/>
</div>
Expand Down Expand Up @@ -264,13 +270,13 @@ export const Layout: React.FC = () => {
>
<Avatar className="border">
<AvatarImage alt="@shadcn" />
<AvatarFallback>{auth?.initials}</AvatarFallback>
<AvatarFallback>{user!.initials}</AvatarFallback>
</Avatar>
<div className={cn("flex flex-col", isCollapsed && "hidden")}>
<span className="block whitespace-nowrap font-semibold">
{auth?.name}
{user!.name}
</span>
<span className="block text-xs opacity-70">{auth?.email}</span>
<span className="block text-xs opacity-70">{user!.email}</span>
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit 9233c5a

Please sign in to comment.