role based content

This commit is contained in:
euzu
2025-08-27 23:12:56 +02:00
parent 4dd088dc98
commit d55827f0dc
17 changed files with 154 additions and 37 deletions
Generated
+1 -1
View File
@@ -987,6 +987,7 @@ name = "frontend"
version = "3.1.5"
dependencies = [
"anyhow",
"base64",
"bytes",
"chrono",
"futures",
@@ -997,7 +998,6 @@ dependencies = [
"implicit-clone",
"js-sys",
"log",
"paste",
"prost",
"regex",
"reqwasm",
+1 -5
View File
@@ -9,7 +9,6 @@ use crate::api::model::{
ProviderStreamFactoryOptions, ProviderStreamInfo, ProviderStreamResponse, SharedStreamManager,
StreamError, ThrottledStream, UserApiRequest,
};
use crate::auth::Claims;
use crate::model::ConfigInput;
use crate::model::{ConfigTarget, ProxyUserCredentials};
use crate::tools::atomic_once_flag::AtomicOnceFlag;
@@ -25,10 +24,7 @@ use chrono::{DateTime, Utc};
use futures::{StreamExt, TryStreamExt};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use log::{debug, error, log_enabled, trace};
use shared::model::{
InputFetchMethod, PlaylistEntry, PlaylistItemType, TargetType, UserConnectionPermission,
XtreamCluster,
};
use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, TargetType, UserConnectionPermission, XtreamCluster};
use shared::utils::{default_grace_period_millis, human_readable_byte_size, trim_slash};
use shared::utils::{
extract_extension_from_url, replace_url_extension, sanitize_sensitive_info, DASH_EXT, HLS_EXT,
+2 -2
View File
@@ -5,7 +5,7 @@ use crate::auth::{create_jwt_admin, create_jwt_user, is_admin, verify_password,
use axum::response::IntoResponse;
use log::error;
use serde_json::json;
use shared::model::{TokenResponse, UserCredential};
use shared::model::{TokenResponse, UserCredential, TOKEN_NO_AUTH};
use shared::utils::{concat_path_leading_slash, CONSTANTS};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -21,7 +21,7 @@ use lol_html::{element, RewriteStrSettings};
fn no_web_auth_token() -> impl axum::response::IntoResponse + Send {
axum::Json(TokenResponse {
token: "authorized".to_string(),
token: TOKEN_NO_AUTH.to_string(),
username: "admin".to_string(),
}).into_response()
}
+1 -12
View File
@@ -6,18 +6,7 @@ use crate::model::WebAuthConfig;
use crate::api::model::AppState;
use crate::auth::AuthBearer;
use shared::error::to_io_error;
const ROLE_ADMIN: &str = "ADMIN";
const ROLE_USER: &str = "USER";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Claims {
pub(crate) username: String,
iss: String,
iat: i64,
exp: i64,
roles: Vec<String>,
}
use shared::model::{Claims, ROLE_ADMIN, ROLE_USER};
pub fn create_jwt_admin(web_auth_config: &WebAuthConfig, username: &str) -> Result<String, std::io::Error> {
create_jwt(web_auth_config, username, vec![ROLE_ADMIN.to_string()])
+1 -1
View File
@@ -28,7 +28,7 @@ prost = "0"
wasm-bindgen-futures = "0"
bytes = "1"
regex = "1.11.1"
paste = "1.0.15"
base64 = "0.22.1"
[dependencies.web-sys]
version = "0.3"
+2 -1
View File
@@ -1,5 +1,6 @@
{
"tabTitle": "tuliprox",
"appTitle": "Tuliprox",
"tabTitle": "Tuliprox",
"appLogo": "/assets/tuliprox-logo.svg",
"api": {
"apiUrl": "/api/v1/",
+2 -1
View File
@@ -67,4 +67,5 @@
@forward "components/config/config_view";
@forward "components/tabset";
@forward "components/select";
@forward "components/form";
@forward "components/form";
@forward "components/api_user/api_user_view";
@@ -0,0 +1,3 @@
div {
color: var(--text-color);
}
@@ -0,0 +1,44 @@
use yew::{function_component, html, Callback, Html};
use crate::app::components::loading_indicator::BusyIndicator;
use crate::app::components::{IconButton, ToastrView};
use crate::hooks::use_service_context;
use crate::provider::DialogProvider;
#[function_component]
pub fn ApiUserView() -> Html {
let services = use_service_context();
let handle_logout = {
let services_ctx = services.clone();
Callback::from(move |_| services_ctx.auth.logout())
};
html! {
<DialogProvider>
<ToastrView />
<div class="tp__app">
<BusyIndicator />
<div class="tp__app-main">
<div class="tp__app-main__header tp__app-header">
<div class="tp__app-main__header-left">
{
if let Some(ref title) = services.config.ui_config.app_title {
html! { title.as_str() }
} else {
html! { /*<AppIcon name="AppTitle" /> */ }
}
}
</div>
<div class={"tp__app-header-toolbar"}>
<IconButton name="Logout" icon="Logout" onclick={handle_logout} />
</div>
</div>
<div class="tp__app-main__body">
{" TODO "}
</div>
</div>
</div>
</DialogProvider>
}
}
@@ -0,0 +1,3 @@
mod api_user_view;
pub use api_user_view::*;
+1
View File
@@ -61,6 +61,7 @@ pub fn Login() -> Html {
let login = do_login.clone();
Callback::from(move |e: KeyboardEvent| {
if e.key() == "Enter" {
e.prevent_default();
login.emit(());
}
})
+3
View File
@@ -38,6 +38,8 @@ mod tabset;
mod select;
mod number_input;
mod date_input;
mod role_based_content;
mod api_user;
// pub use self::input::*;
// pub use self::menu_item::*;
// pub use self::popup_menu::*;
@@ -74,5 +76,6 @@ pub use self::accordion::*;
pub use self::accordion_panel::*;
pub use self::csv_table::*;
pub use self::tabset::*;
pub use self::role_based_content::*;
//pub use self::number_input::*;
//pub use self::date_input::*;
@@ -0,0 +1,18 @@
use yew::prelude::*;
use yew_router::Switch;
use crate::app::{switch, AppRoute};
use crate::app::components::api_user::ApiUserView;
use crate::hooks::use_service_context;
#[function_component]
pub fn RoleBasedContent() -> Html {
let services = use_service_context();
if services.auth.is_admin() {
html! { <Switch<AppRoute> render={switch} /> }
} else if services.auth.is_user() {
html! { <ApiUserView /> }
} else {
html! { "Not authorized" }
}
}
+14 -4
View File
@@ -6,13 +6,14 @@ use std::rc::Rc;
use futures::future::join_all;
use log::error;
use serde_json::Value;
use web_sys::window;
use crate::provider::IconContextProvider;
use crate::provider::ServiceContextProvider;
use yew_i18n::I18nProvider;
use yew::prelude::*;
use yew_hooks::{use_async_with_options, UseAsyncOptions};
use yew_router::prelude::*;
use crate::app::components::{Authentication, Home, Login};
use crate::app::components::{Authentication, Home, Login, RoleBasedContent};
use crate::error::Error;
use crate::hooks::{IconDefinition};
use crate::model::WebConfig;
@@ -101,8 +102,17 @@ pub fn App() -> Html {
{
let config_state = configuration_state.clone();
use_async_with_options::<_, (), Error>(async move {
match request_get("config.json", None, None).await {
Ok(cfg) => config_state.set(Some(cfg)),
match request_get::<WebConfig>("config.json", None, None).await {
Ok(cfg) => {
if let Some(tab_title) = cfg.tab_title.as_deref() {
if let Some(win) = window() {
if let Some(doc) = win.document() {
doc.set_title(tab_title);
}
}
}
config_state.set(Some(cfg));
},
Err(err) => error!("Failed to load config {err}"),
}
Ok(())
@@ -134,7 +144,7 @@ pub fn App() -> Html {
<IconContextProvider icons={icons.clone()}>
<I18nProvider supported_languages={supported_languages} translations={transl.clone()}>
<Authentication>
<Switch<AppRoute> render={switch} />
<RoleBasedContent />
</Authentication>
</I18nProvider>
</IconContextProvider>
+4 -3
View File
@@ -1,7 +1,8 @@
use std::rc::Rc;
use yew::prelude::*;
use crate::model::WebConfig;
use crate::services::{AuthService, ConfigService, EventService, PlaylistService, StatusService, ToastrService, UserService, WebSocketService};
use crate::services::{AuthService, ConfigService, EventService, PlaylistService, StatusService, ToastrService,
UserService, WebSocketService};
pub struct Services {
pub auth: Rc<AuthService>,
@@ -15,9 +16,9 @@ pub struct Services {
}
impl Services {
pub fn new(config: &WebConfig) -> Self {
pub fn new(web_config: &WebConfig) -> Self {
let config = Rc::new(ConfigService::new(web_config));
let auth = Rc::new(AuthService::new());
let config = Rc::new(ConfigService::new(config));
let status = Rc::new(StatusService::new());
let event = Rc::new(EventService::new());
let playlist = Rc::new(PlaylistService::new());
+40 -7
View File
@@ -1,17 +1,25 @@
use std::cell::RefCell;
use super::{get_base_href, request_post};
use super::{get_base_href, request_post, ConfigService};
use crate::error::Error;
use crate::services::requests::set_token;
use futures_signals::signal::Mutable;
use futures_signals::signal::SignalExt;
use shared::model::{TokenResponse, UserCredential};
use shared::model::{Claims, TokenResponse, UserCredential, ROLE_ADMIN, ROLE_USER, TOKEN_NO_AUTH};
use std::future::Future;
use shared::utils::{concat_path, concat_path_leading_slash};
use base64::{engine::general_purpose, Engine as _};
use log::warn;
fn decode_jwt_payload(token: &str) -> Option<Claims> {
let payload_enc = token.split('.').nth(1)?;
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload_enc).ok()?;
serde_json::from_slice::<Claims>(&payload_bytes).ok()
}
#[derive(Debug)]
pub struct AuthService {
auth_path: String,
username: RefCell<String>,
roles: RefCell<Vec<String>>,
auth_channel: Mutable<bool>,
}
@@ -22,12 +30,24 @@ impl AuthService {
auth_path: concat_path_leading_slash(&base_href, "auth"),
username: RefCell::new(String::new()),
auth_channel: Mutable::new(false),
roles: RefCell::new(vec![]),
}
}
pub fn get_username(&self) -> String {
self.username.borrow().to_string()
}
pub fn is_admin(&self) -> bool {
self.roles.borrow().iter().any(|r| r == ROLE_ADMIN)
}
pub fn is_user(&self) -> bool {
self.roles.borrow().iter().any(|r| r == ROLE_USER)
}
pub fn is_authenticated(&self) -> bool {
self.auth_channel.get()
}
pub async fn auth_subscribe<F, U>(&self, callback: &mut F)
where
@@ -54,6 +74,7 @@ impl AuthService {
self.username.replace(token.username.to_string());
self.auth_channel.set(true);
set_token(Some(&token.token));
self.handle_token(&token.token);
Ok(token)
}
Err(e) => {
@@ -71,6 +92,7 @@ impl AuthService {
self.username.replace(token.username.to_string());
self.auth_channel.set(true);
set_token(Some(&token.token));
self.handle_token(&token.token);
Ok(token)
}
Err(e) => {
@@ -81,10 +103,21 @@ impl AuthService {
}
}
}
}
impl Default for AuthService {
fn default() -> Self {
Self::new()
fn handle_token(&self, token: &str) {
let mut roles = self.roles.borrow_mut();
roles.clear();
if token == TOKEN_NO_AUTH {
roles.push(ROLE_ADMIN.to_string());
}
if let Some(claims) = decode_jwt_payload(token) {
for role in claims.roles.iter() {
roles.push(role.clone());
}
} else {
warn!("no claims");
}
}
}
+14
View File
@@ -1,5 +1,19 @@
use zeroize::Zeroize;
pub const TOKEN_NO_AUTH: &str = "authorized";
pub const ROLE_ADMIN: &str = "ADMIN";
pub const ROLE_USER: &str = "USER";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Claims {
pub username: String,
pub iss: String,
pub iat: i64,
pub exp: i64,
pub roles: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UserCredential {
pub username: String,