diff --git a/.gitignore b/.gitignore index cf2dd4c..96397dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .env target/ .idea/ +keys/ **/Cargo.lock .DS_Store diff --git a/README.md b/README.md index f5b4a75..47e71ff 100644 --- a/README.md +++ b/README.md @@ -68,4 +68,9 @@ The application can also be tested from within a Docker container: ``` docker build -t siren:latest . docker run --env-file .env -it --rm --name siren siren:latest -``` \ No newline at end of file +``` + +### Authentication +The Siren service uses a JWT/session based authentication system, in that JWT tokens are issued and used, but a state is also kept server-side. This is to allow for the ability to revoke and expire tokens, as well as to allow for the ability to have multiple tokens per user. + +Public/Private keys can be generated with `make generate`. These keys should be located within a `/keys` directory in the root of the project. \ No newline at end of file diff --git a/service/.env.TEMPLATE b/service/.env.TEMPLATE index 78817a9..8199c5f 100644 --- a/service/.env.TEMPLATE +++ b/service/.env.TEMPLATE @@ -6,12 +6,8 @@ DATABASE_NAME=siren DATABASE_HOST=localhost DATABASE_PORT=5432 -ACCESS_TOKEN_PRIVATE_KEY= -ACCESS_TOKEN_PUBLIC_KEY= +KEYS_DIR_PATH= ACCESS_TOKEN_MAXAGE=5 - -REFRESH_TOKEN_PRIVATE_KEY= -REFRESH_TOKEN_PUBLIC_KEY= REFRESH_TOKEN_MAXAGE=30 REDIS_HOST=localhost diff --git a/service/Dockerfile b/service/Dockerfile index 7700c67..763b8cc 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -11,6 +11,18 @@ COPY Cargo.toml ./ RUN apt-get update && apt-get install -y cmake RUN cargo build --release +# ====== +# Keys +# ====== +FROM debian:bookworm-slim as keys +WORKDIR /keys + +RUN apt-get update && apt-get install -y openssl libpq-dev +RUN openssl genrsa -out access.pem 4096 +RUN openssl rsa -in access.pem -pubout -outform PEM -out access.pem.pub +RUN openssl genrsa -out refresh.pem 4096 +RUN openssl rsa -in refresh.pem -pubout -outform PEM -out refresh.pem.pub + # ========== # Packages # ========== @@ -51,6 +63,7 @@ USER root COPY --from=builder /builder/target/release/service /usr/local/bin/service COPY --from=packages /packages /usr/bin +COPY --from=keys /keys /keys RUN apt-get update && apt-get install -y libc6 libc6-dev libopus-dev libpq5 libpq-dev python3-pip ffmpeg diff --git a/service/Makefile b/service/Makefile index ed6d42a..a501bcf 100644 --- a/service/Makefile +++ b/service/Makefile @@ -28,4 +28,11 @@ clean: docker compose down && \ docker image rm siren-service || \ docker network rm siren_frontend || \ - docker network rm siren-backend \ No newline at end of file + docker network rm siren-backend + +generate: ## Generate RSA keys + mkdir keys + openssl genrsa -out keys/access_private_key.pem 4096 + openssl rsa -in keys/access_private_key.pem -pubout -outform PEM -out keys/access_public_key.pem + openssl genrsa -out keys/refresh_private_key.pem 4096 + openssl rsa -in keys/refresh_private_key.pem -pubout -outform PEM -out keys/refresh_public_key.pem \ No newline at end of file diff --git a/service/docker-compose.yml b/service/docker-compose.yml index c6880a0..0350a42 100644 --- a/service/docker-compose.yml +++ b/service/docker-compose.yml @@ -24,6 +24,7 @@ services: SERVICE_HOST: service SERVICE_PORT: 5000 DATA_DIR_PATH: /data + KEYS_DIR_PATH: /keys volumes: - ${DATA_DIR_PATH}:/data ports: diff --git a/service/src/auth/mod.rs b/service/src/auth/mod.rs index fc63e4d..3fd4ffc 100644 --- a/service/src/auth/mod.rs +++ b/service/src/auth/mod.rs @@ -1,7 +1,6 @@ use std::env; use argon2::{password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString, Error as HashError}, Argon2, PasswordHash}; -use base64::{engine::general_purpose, Engine as _}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, encode, decode, Validation, Algorithm}; use serde::{Deserialize, Serialize}; @@ -14,62 +13,113 @@ use siren::ServiceError; #[derive(Debug, Serialize, Deserialize)] struct TokenClaims { - sub: String, // Subject + sub: String, // Subject (User) token_uuid: String, // Token UUID - iss: String, // Issuer + iss: String, // Issuer (Service) exp: i64, // Expiration time iat: i64, // Issued At nbf: i64 // Not Before } +#[derive(Debug, Serialize, Deserialize)] +pub enum TokenType { + #[serde(rename = "access")] + Access, + #[serde(rename = "refresh")] + Refresh, + #[serde(rename = "none")] + None +} + +impl std::fmt::Display for TokenType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenType::Access => write!(f, "access"), + TokenType::Refresh => write!(f, "refresh"), + TokenType::None => write!(f, "none") + } + } +} + +impl std::str::FromStr for TokenType { + type Err = ServiceError; + + fn from_str(s: &str) -> Result { + match s { + "access" => Ok(TokenType::Access), + "refresh" => Ok(TokenType::Refresh), + "none" => Ok(TokenType::None), + _ => Err(ServiceError::new(400, "Invalid token type".to_string())) + } + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct TokenDetails { pub token: Option, pub token_uuid: uuid::Uuid, pub email: String, + pub token_type: TokenType, pub expires_in: Option } -// https://codevoweb.com/rust-actix-web-jwt-access-and-refresh-tokens/ -// https://github.com/wpcodevo/rust-jwt-rs256/blob/master/src/main.rs +impl std::fmt::Display for TokenDetails { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}:{}", self.token_type.to_string(), self.email, self.token_uuid.to_string()) + } +} + +impl std::str::FromStr for TokenDetails { + type Err = ServiceError; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split(":").collect(); + if parts.len() != 2 { + return Err(ServiceError::new(400, "Invalid token".to_string())); + } + let token_type = parts[0].parse::()?; + let email = parts[1].to_string(); + let uuid = uuid::Uuid::parse_str(parts[2])?; + Ok(TokenDetails { token: None, token_uuid: uuid, email, token_type, expires_in: None }) + } +} pub fn verify_token(token: &str, public_key: &str) -> Result { - let bytes_public_key = general_purpose::STANDARD.decode(public_key).unwrap(); - let decoded_public_key = String::from_utf8(bytes_public_key).unwrap(); - let key = DecodingKey::from_rsa_pem(decoded_public_key.as_bytes())?; + let key = DecodingKey::from_rsa_pem(public_key.as_bytes())?; let validation = Validation::new(Algorithm::RS256); let decoded = decode::(token, &key, &validation)?; let email = decoded.claims.sub; let token_uuid = uuid::Uuid::parse_str(decoded.claims.token_uuid.as_str()).unwrap(); - Ok(TokenDetails { token: None, token_uuid, email, expires_in: None }) + Ok(TokenDetails { token: None, token_uuid, email, token_type: TokenType::None, expires_in: None }) } pub fn generate_access_token(email: &str) -> Result { let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE") - .expect("ACCESS_TOKEN_MAXAGE must be set") - .parse::() - .expect("ACCESS_TOKEN_MAXAGE must be an integer"); - let access_private_key = env::var("ACCESS_TOKEN_PRIVATE_KEY") - .expect("ACCESS_TOKEN_PRIVATE_KEY must be set"); - generate_token(&email, access_token_max_age, &access_private_key) + .expect("ACCESS_TOKEN_MAXAGE must be set") + .parse::() + .expect("ACCESS_TOKEN_MAXAGE must be an integer"); + let keys_dir = env::var("KEYS_DIR_PATH")?; + let access_private_key = std::fs::read_to_string(format!("{}/access_private_key.pem", keys_dir))?; + generate_token(&email, TokenType::Refresh, access_token_max_age, &access_private_key) } pub fn generate_refresh_token(email: &str) -> Result { let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE") - .expect("REFRESH_TOKEN_MAXAGE must be set") - .parse::() - .expect("REFRESH_TOKEN_MAXAGE must be an integer"); - let refresh_private_key = env::var("REFRESH_TOKEN_PRIVATE_KEY") - .expect("REFRESH_TOKEN_PRIVATE_KEY must be set"); - generate_token(&email, refresh_token_max_age, &refresh_private_key) + .expect("REFRESH_TOKEN_MAXAGE must be set") + .parse::() + .expect("REFRESH_TOKEN_MAXAGE must be an integer"); + let keys_dir = env::var("KEYS_DIR_PATH")?; + let refresh_private_key = std::fs::read_to_string(format!("{}/refresh_private_key.pem", keys_dir))?; + generate_token(&email, TokenType::Access, refresh_token_max_age, &refresh_private_key) } -pub fn generate_token(email: &str, ttl: i64, private_key: &str) -> Result { +fn generate_token(email: &str, token_type: TokenType, ttl: i64, private_key: &str) -> Result { let now = chrono::Utc::now(); let mut token_details = TokenDetails { token: None, token_uuid: uuid::Uuid::new_v4(), email: email.to_string(), + token_type, expires_in: Some((now + chrono::Duration::minutes(ttl)).timestamp()) }; let claims = TokenClaims { @@ -81,9 +131,7 @@ pub fn generate_token(email: &str, ttl: i64, private_key: &str) -> Result Result { pub fn verify_password(hash: &str, password: &[u8]) -> Result<(), HashError> { let parsed_hash = PasswordHash::new(hash)?; Ok(Argon2::default().verify_password(password, &parsed_hash)?) -} \ No newline at end of file +} diff --git a/service/src/auth/model.rs b/service/src/auth/model.rs index af557ef..7ecf064 100644 --- a/service/src/auth/model.rs +++ b/service/src/auth/model.rs @@ -145,8 +145,8 @@ impl FromRequest for JwtAuth { }))) }; - let public_key = env::var("ACCESS_TOKEN_PUBLIC_KEY") - .expect("ACCESS_TOKEN_PUBLIC_KEY must be set"); + let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set"); + let public_key = std::fs::read_to_string(format!("{}/access_public_key.pem", keys_dir)).expect("Failed to read access public key"); let access_token_details = match verify_token(&access_token, &public_key) { Ok(token_details) => token_details, diff --git a/service/src/auth/routes.rs b/service/src/auth/routes.rs index e794d09..efa9fb8 100644 --- a/service/src/auth/routes.rs +++ b/service/src/auth/routes.rs @@ -76,7 +76,7 @@ async fn login(request: web::Json) -> HttpResponse { .parse::() .expect("REFRESH_TOKEN_MAXAGE must be an integer"); - let access_result: redis::RedisResult<()> = conn.set_ex(access_token_details.token_uuid.to_string(), &email, (access_token_max_age * 60) as usize).await; + let access_result: redis::RedisResult<()> = conn.set_ex(access_token_details.token_uuid.to_string(), &access_token_details.to_string(), (access_token_max_age * 60) as usize).await; if let Err(err) = access_result { error!("Failed to set access token in redis: {}", err); return ResponseError::error_response(&ServiceError { @@ -85,7 +85,7 @@ async fn login(request: web::Json) -> HttpResponse { }) }; - let refresh_result: redis::RedisResult<()> = conn.set_ex(refresh_token_details.token_uuid.to_string(), &email, (refresh_token_max_age * 60) as usize).await; + let refresh_result: redis::RedisResult<()> = conn.set_ex(refresh_token_details.token_uuid.to_string(), &refresh_token_details.to_string(), (refresh_token_max_age * 60) as usize).await; if let Err(err) = refresh_result { error!("Failed to set refresh token in redis: {}", err); return ResponseError::error_response(&ServiceError { @@ -150,8 +150,9 @@ async fn refresh(req: HttpRequest) -> HttpResponse { }) }; - let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY") - .expect("REFRESH_TOKEN_PUBLIC_KEY must be set"); + let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set"); + let public_key = std::fs::read_to_string(format!("{}/refresh_public_key.pem", keys_dir)) + .expect("Unable to read refresh public key"); let refresh_token_details = match verify_token(&refresh_token, &public_key) { Ok(token_details) => token_details, Err(err) => return ResponseError::error_response(&err) @@ -177,12 +178,12 @@ async fn refresh(req: HttpRequest) -> HttpResponse { } }; - // Delete old auth token if it exists + // Delete old access token if it exists match req.cookie("access_token") { Some(cookie) => { let access_token = cookie.value().to_string(); - let public_key = env::var("ACCESS_TOKEN_PUBLIC_KEY") - .expect("ACCESS_TOKEN_PUBLIC_KEY must be set"); + let public_key = std::fs::read_to_string(format!("{}/access_public_key.pem", keys_dir)) + .expect("Unable to read access public key"); match verify_token(&access_token, &public_key) { Ok(token_details) => { let _: redis::RedisResult<()> = conn.del(token_details.token_uuid.to_string()).await; @@ -199,7 +200,7 @@ async fn refresh(req: HttpRequest) -> HttpResponse { .parse::() .expect("ACCESS_TOKEN_MAXAGE must be an integer"); - let access_result: redis::RedisResult<()> = conn.set_ex(access_token_details.token_uuid.to_string(), &email, (access_token_max_age * 60) as usize).await; + let access_result: redis::RedisResult<()> = conn.set_ex(access_token_details.token_uuid.to_string(), &access_token_details.to_string(), (access_token_max_age * 60) as usize).await; if let Err(err) = access_result { error!("Failed to set access token in redis: {}", err); return ResponseError::error_response(&ServiceError { @@ -244,7 +245,7 @@ async fn refresh(req: HttpRequest) -> HttpResponse { .parse::() .expect("REFRESH_TOKEN_MAXAGE must be an integer"); - let refresh_result: redis::RedisResult<()> = conn.set_ex(refresh_token_details.token_uuid.to_string(), &refresh_token_details.email, (refresh_token_max_age * 60) as usize).await; + let refresh_result: redis::RedisResult<()> = conn.set_ex(refresh_token_details.token_uuid.to_string(), &refresh_token_details.to_string(), (refresh_token_max_age * 60) as usize).await; if let Err(err) = refresh_result { error!("Failed to set refresh token in redis: {}", err); return ResponseError::error_response(&ServiceError { @@ -278,6 +279,7 @@ async fn refresh(req: HttpRequest) -> HttpResponse { #[post("/logout")] async fn logout(req: HttpRequest, auth: JwtAuth) -> HttpResponse { + let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set"); let refresh_token = match req.cookie("refresh_token") { Some(cookie) => cookie.value().to_string(), None => return ResponseError::error_response(&ServiceError { @@ -285,8 +287,8 @@ async fn logout(req: HttpRequest, auth: JwtAuth) -> HttpResponse { message: "Refresh token not found".to_string() }) }; - let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY") - .expect("REFRESH_TOKEN_PUBLIC_KEY must be set"); + let public_key = std::fs::read_to_string(format!("{}/refresh_public_key.pem", keys_dir)) + .expect("Unable to read refresh public key"); let refresh_token_details = match verify_token(&refresh_token, &public_key) { Ok(token_details) => token_details, Err(err) => return ResponseError::error_response(&err) @@ -342,12 +344,13 @@ async fn me(auth: JwtAuth) -> HttpResponse { #[get("/check-session")] async fn check_session(req: HttpRequest) -> HttpResponse { + let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set"); // If there is a access_token cookie, check if it is valid let has_session = match req.cookie("access_token") { Some(cookie) => { let access_token = cookie.value().to_string(); - let public_key = env::var("ACCESS_TOKEN_PUBLIC_KEY") - .expect("ACCESS_TOKEN_PUBLIC_KEY must be set"); + let public_key = std::fs::read_to_string(format!("{}/access_public_key.pem", keys_dir)) + .expect("Unable to read access public key"); match verify_token(&access_token, &public_key) { Ok(_) => true, Err(_) => false @@ -360,8 +363,8 @@ async fn check_session(req: HttpRequest) -> HttpResponse { match req.cookie("refresh_token") { Some(cookie) => { let refresh_token = cookie.value().to_string(); - let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY") - .expect("REFRESH_TOKEN_PUBLIC_KEY must be set"); + let public_key = std::fs::read_to_string(format!("{}/refresh_public_key.pem", keys_dir)) + .expect("Unable to read refresh public key"); match verify_token(&refresh_token, &public_key) { Ok(_) => return HttpResponse::Ok().json(true), Err(_) => return HttpResponse::Ok().json(false) @@ -380,6 +383,7 @@ async fn roles() -> HttpResponse { } pub fn init_routes(config: &mut web::ServiceConfig) { + // TODO: Remove this when deploying let r = RegisterUser { email: "admin".to_string(), password: "admin".to_string(), diff --git a/service/src/lib.rs b/service/src/lib.rs index eb30dbd..27994f7 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -141,6 +141,18 @@ impl From for ServiceError { } } +impl From for ServiceError { + fn from(error: uuid::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown uuid error: {}", error)) + } +} + +impl From for ServiceError { + fn from(error: std::env::VarError) -> ServiceError { + ServiceError::new(500, format!("Unknown env error: {}", error)) + } +} + impl ResponseError for ServiceError { fn error_response(&self) -> HttpResponse { let status_code = match StatusCode::from_u16(self.status) { diff --git a/ui/next.config.js b/ui/next.config.js index 42643a9..7bfee5e 100755 --- a/ui/next.config.js +++ b/ui/next.config.js @@ -5,13 +5,6 @@ const nextConfig = { eslint: { ignoreDuringBuilds: true }, - webpackDevMiddleware: (config) => { - config.watchOptions = { - poll: 1000, - aggregateTimeout: 300 - }; - return config; - }, publicRuntimeConfig: { // remove private variables from processEnv processEnv: Object.fromEntries(Object.entries(process.env).filter(([key]) => key.includes('NEXT_PUBLIC_'))) diff --git a/ui/src/app/admin/page.tsx b/ui/src/app/admin/page.tsx index a08f5e3..6668881 100644 --- a/ui/src/app/admin/page.tsx +++ b/ui/src/app/admin/page.tsx @@ -14,6 +14,7 @@ import { stopTrack } from '@/api/guilds'; import { GuildChannel, GuildInfo } from '@/api/guilds.types'; +import Auth from '@/components/Auth'; import { userState } from '@/state/auth'; import { Button, Card, Grid, Select, Slider, Tabs, TextInput, Textarea } from '@mantine/core'; import { useForm } from '@mantine/form'; @@ -21,7 +22,7 @@ import { useRouter } from 'next/navigation'; import React, { useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; -export default function Page() { +function Page() { const user = useRecoilValue(userState); const [guilds, setGuilds] = useState([]); const [activeGuild, setActiveGuild] = useState(null); @@ -68,6 +69,8 @@ export default function Page() { ); } +export default Auth(Page); + function TextChannelCard({ guild }: { guild: GuildInfo | null }) { const [textChannels, setTextChannels] = useState([]); const [activeChannel, setActiveChannel] = useState(null); diff --git a/ui/src/app/campaigns/page.tsx b/ui/src/app/campaigns/page.tsx index d7cf7b2..138882a 100644 --- a/ui/src/app/campaigns/page.tsx +++ b/ui/src/app/campaigns/page.tsx @@ -3,15 +3,8 @@ import { ActionIcon, Tooltip } from '@mantine/core'; import { FaPlus } from "react-icons/fa"; import React, { useEffect } from 'react'; -import { getCampigns } from '@/api/campaigns'; -import { Campaign } from '@/api/campaigns.types'; export default function Page() { - const [campaigns, setCampaigns] = React.useState([]); - - useEffect(() => { - getCampigns().then((data) => setCampaigns(data)); - }, []); return (
@@ -21,9 +14,6 @@ export default function Page() { - {campaigns && campaigns.map((campaign) => ( -
{campaign.name}
- ))}
); } diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 07eaab6..765fa60 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -8,7 +8,6 @@ import { Notifications } from '@mantine/notifications'; import 'styles/globals.css'; import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; -import Loading from '@/components/Loading'; export const metadata = { title: 'Siren', @@ -28,10 +27,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - -
- {children} - +
+ {children} diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx index 7b29c53..d3d4cde 100644 --- a/ui/src/app/page.tsx +++ b/ui/src/app/page.tsx @@ -4,18 +4,18 @@ import React from 'react'; // Home page for siren export default function Page() { return ( - //
- //

Siren is a Dungeon Master's best friend.

- //

Features:

- //
    - //
  • Manage your campaign and players
  • - //
  • Create battlemaps on the fly and track initiative
  • - //
  • Connect the Discord Bot to play online with friends
  • - //
  • Reference Races, Classes, Items, Spells, and more
  • - //
- //
-
- +
+

Siren is a Dungeon Master's best friend.

+

Features:

+
    +
  • Manage your campaign and players
  • +
  • Create battlemaps on the fly and track initiative
  • +
  • Connect the Discord Bot to play online with friends
  • +
  • Reference Races, Classes, Items, Spells, and more
  • +
+ //
+ // + //
); } diff --git a/ui/src/components/Auth.tsx b/ui/src/components/Auth.tsx new file mode 100644 index 0000000..b17002b --- /dev/null +++ b/ui/src/components/Auth.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { hasUserState, isAdminState } from "@/state/auth"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useRecoilValue } from "recoil"; + +export default function Auth(Component: any, adminOnly = false) { + return function AuthWrapper(props: any) { + const router = useRouter(); + const hasUser = useRecoilValue(hasUserState); + const isAdmin = useRecoilValue(isAdminState); + + function isAuthenticated() { + console.log('hasUser', hasUser, 'adminOnly', adminOnly, 'isAdmin', isAdmin) + return hasUser && (adminOnly ? isAdmin : true); + } + + useEffect(() => { + console.log('isAuthenticated', isAuthenticated()); + if (!isAuthenticated) { + router.push('/'); + } + }, []); + + if (!isAuthenticated) { + return null; + } + + return ; + } +} \ No newline at end of file diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index b2a0e80..51ca566 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -199,7 +199,8 @@ export default function Header() { type={modalType} toggle={toggle} setUser={(u) => { - setUser(u); + console.log(u); + setUser(u); updateUser(u); }} setRefreshId={setRefreshId} diff --git a/ui/src/middleware.ts b/ui/src/middleware.ts new file mode 100644 index 0000000..643627c --- /dev/null +++ b/ui/src/middleware.ts @@ -0,0 +1,6 @@ +'use client'; + +import { NextRequest } from "next/server"; + +export default function middleware(request: NextRequest) { +} \ No newline at end of file diff --git a/ui/src/state/auth.ts b/ui/src/state/auth.ts index e05ec95..5b49ec9 100644 --- a/ui/src/state/auth.ts +++ b/ui/src/state/auth.ts @@ -1,7 +1,24 @@ import { User } from '@/api/auth.types'; -import { atom } from 'recoil'; +import { atom, selector } from 'recoil'; export const userState = atom({ key: 'userState', default: undefined as User | undefined }); + +export const hasUserState = selector({ + key: 'hasUserState', + get: ({ get }) => { + const user = get(userState); + return user !== undefined; + } +}); + +export const isAdminState = selector({ + key: 'isAdminState', + get: ({ get }) => { + const user = get(userState); + return user?.role === 'admin'; + } +}); +