diff --git a/CHANGELOG.md b/CHANGELOG.md index b8cec49..4d72be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [0.1.8] - 2024-07-07 + +## Added +- Restore deleted emails (Enterprise edition only). +- Option to purge accounts. + +### Changed + +### Fixed + ## [0.1.7] - 2024-07-01 ## Added diff --git a/Cargo.lock b/Cargo.lock index e26afe5..6ea80dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2243,7 +2243,7 @@ dependencies = [ [[package]] name = "webadmin" -version = "0.1.7" +version = "0.1.8" dependencies = [ "ahash", "base64", diff --git a/Cargo.toml b/Cargo.toml index 6c8ae3a..ec7f739 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art" keywords = ["web", "admin", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.1.7" +version = "0.1.8" edition = "2021" resolver = "2" diff --git a/src/components/icon.rs b/src/components/icon.rs index 1d7bbde..620f429 100644 --- a/src/components/icon.rs +++ b/src/components/icon.rs @@ -577,3 +577,33 @@ pub fn IconSquare2x2( } } + +#[component] +pub fn IconArrowUTurnLeft( + #[prop(optional)] size: Option, + #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, +) -> impl IntoView { + view! { + + + + } +} + +#[component] +pub fn IconThreeDots( + #[prop(optional)] size: Option, + #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, +) -> impl IntoView { + view! { + + + + + + } +} diff --git a/src/core/oauth.rs b/src/core/oauth.rs index 3e015c7..d9efda2 100644 --- a/src/core/oauth.rs +++ b/src/core/oauth.rs @@ -21,6 +21,7 @@ pub struct AuthToken { pub username: Arc, pub is_valid: bool, pub is_admin: bool, + pub is_enterprise: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,6 +40,8 @@ pub enum OAuthCodeRequest { pub struct OAuthCodeResponse { pub code: String, pub is_admin: bool, + #[serde(default)] + pub is_enterprise: bool, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -90,6 +93,7 @@ pub enum AuthenticationResult { pub struct AuthenticationResponse { pub grant: OAuthGrant, pub is_admin: bool, + pub is_enterprise: bool, } pub async fn oauth_authenticate( @@ -104,6 +108,7 @@ pub async fn oauth_authenticate( AuthenticationResult::Error(err) => return AuthenticationResult::Error(err), }; let is_admin = response.is_admin; + let is_enterprise = response.is_enterprise; match HttpRequest::post(format!("{base_url}/auth/token")) .with_raw_body( serde_urlencoded::to_string([ @@ -120,7 +125,11 @@ pub async fn oauth_authenticate( serde_json::from_slice::(response.as_slice()).map_err(Into::into) }) { Ok(OAuthResponse::Granted(grant)) => { - AuthenticationResult::Success(AuthenticationResponse { grant, is_admin }) + AuthenticationResult::Success(AuthenticationResponse { + grant, + is_admin, + is_enterprise, + }) } Ok(OAuthResponse::Error { error }) => AuthenticationResult::Error( Alert::error("OAuth failure") @@ -223,6 +232,10 @@ impl AuthToken { pub fn is_admin(&self) -> bool { self.is_admin && self.is_logged_in() } + + pub fn is_enterprise(&self) -> bool { + self.is_enterprise + } } impl AsRef for AuthToken { diff --git a/src/core/schema.rs b/src/core/schema.rs index d355e83..a07d4f1 100644 --- a/src/core/schema.rs +++ b/src/core/schema.rs @@ -51,6 +51,7 @@ pub struct Field { pub placeholder: Value<&'static str>, pub display: Vec, pub readonly: bool, + pub enterprise: bool, } #[derive(Clone, Default, Debug)] @@ -597,6 +598,11 @@ impl Builder<(Schemas, Schema), Field> { self } + pub fn enterprise_feature(mut self) -> Self { + self.item.enterprise = true; + self + } + pub fn typ(mut self, typ_: Type<&'static str, &'static str>) -> Self { self.item.typ_ = match typ_ { Type::Select { diff --git a/src/main.rs b/src/main.rs index 47a15b3..235c86c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ use pages::{ mfa::ManageMfa, }, config::edit::DEFAULT_SETTINGS_URL, + enterprise::undelete::UndeleteList, manage::spam::{SpamTest, SpamTrain}, }; @@ -137,6 +138,7 @@ pub fn App() -> impl IntoView { let is_logged_in = create_memo(move |_| auth_token.get().is_logged_in()); let is_admin = create_memo(move |_| auth_token.get().is_admin()); + let is_enterprise = create_memo(move |_| auth_token.get().is_enterprise()); view! { @@ -241,6 +243,12 @@ pub fn App() -> impl IntoView { redirect_path="/login" condition=move || is_admin.get() /> + impl IntoView { }; let schema = current_schema.get(); let sections = schema.form.sections.iter().cloned(); + let is_enterprise = auth.get().is_enterprise(); data.set( FormData::from_settings(schema.clone(), settings) .with_external_sources(external_sources), @@ -358,7 +359,8 @@ pub fn SettingsEdit() -> impl IntoView { .iter() .cloned() .map(|field| { - let is_disabled = field.readonly && !is_create; + let is_disabled = (field.readonly && !is_create) + || (!is_enterprise && field.enterprise); let field_label = field.label_form; let help = field.help; let field_ = field.clone(); @@ -444,7 +446,10 @@ pub fn SettingsEdit() -> impl IntoView { } Type::Duration => { view! { - + } .into_view() } diff --git a/src/pages/config/schema/storage.rs b/src/pages/config/schema/storage.rs index 8c3be2f..22af31b 100644 --- a/src/pages/config/schema/storage.rs +++ b/src/pages/config/schema/storage.rs @@ -153,6 +153,16 @@ impl Builder { .default("30d") .typ(Type::Duration) .build() + .new_field("enterprise.undelete-period") + .label("Un-delete period 💎") + .help(concat!( + "How long to keep deleted emails before they are permanently ", + "removed from the system. (Enterprise feature)" + )) + .default("false") + .typ(Type::Duration) + .enterprise_feature() + .build() .new_form_section() .title("Data Store") .fields([ @@ -163,7 +173,7 @@ impl Builder { .build() .new_form_section() .title("Blob Store") - .fields(["storage.blob"]) + .fields(["storage.blob", "enterprise.undelete-period"]) .build() .new_form_section() .title("Full Text Index Store") diff --git a/src/pages/directory/principals/list.rs b/src/pages/directory/principals/list.rs index c2c7f6d..5a006b3 100644 --- a/src/pages/directory/principals/list.rs +++ b/src/pages/directory/principals/list.rs @@ -13,7 +13,7 @@ use leptos_router::*; use crate::{ components::{ badge::Badge, - icon::{IconAdd, IconTrash}, + icon::{IconAdd, IconThreeDots, IconTrash}, list::{ header::ColumnList, pagination::Pagination, @@ -153,6 +153,30 @@ pub fn PrincipalList() -> impl IntoView { ))); } }); + let purge_action = create_action(move |item: &String| { + let item = item.clone(); + let auth = auth.get(); + + async move { + + match HttpRequest::get(("/api/store/purge/account", &item)) + .with_authorization(&auth) + .send::<()>() + .await { + Ok(_) => { + alert.set(Alert::success(format!( + "Account purge requested for {item}.", + + ))); + + }, + Err(err) => { + alert.set(Alert::from(err)); + }, + } + + } + }); let total_results = create_rw_signal(None::); let title = Signal::derive(move || { @@ -323,7 +347,17 @@ pub fn PrincipalList() -> impl IntoView { key=|principal| principal.name.clone().unwrap_or_default() let:principal > - + + } @@ -388,8 +422,17 @@ pub fn PrincipalList() -> impl IntoView { } } +struct Parameters { + selected_type: PrincipalType, + is_enterprise: bool, + delete_action: Action>, ()>, + purge_action: Action, + modal: RwSignal, +} + #[component] -fn PrincipalItem(principal: Principal, selected_type: PrincipalType) -> impl IntoView { +fn PrincipalItem(principal: Principal, params: Parameters) -> impl IntoView { + let selected_type = params.selected_type; let name = principal.name.as_deref().unwrap_or("unknown").to_string(); let display_name = principal .description @@ -403,13 +446,17 @@ fn PrincipalItem(principal: Principal, selected_type: PrincipalType) -> impl Int .and_then(|ch| ch.to_uppercase().next()) .unwrap_or_default(); let principal_id = principal.name.as_deref().unwrap_or_default().to_string(); + let principal_id_2 = principal_id.clone(); + let principal_id_3 = principal_id.clone(); let manage_url = format!( "/manage/directory/{}/{}/edit", - selected_type.resource_name(), + params.selected_type.resource_name(), principal_id ); + let undelete_url = format!("/manage/undelete/{principal_id}",); let num_members = principal.members.len(); let num_member_of = principal.member_of.len(); + let show_dropdown = RwSignal::new(false); view! { @@ -485,12 +532,98 @@ fn PrincipalItem(principal: Principal, selected_type: PrincipalType) -> impl Int {maybe_plural(num_member_of, "group", "groups")} - - Edit - + + diff --git a/src/pages/enterprise/mod.rs b/src/pages/enterprise/mod.rs new file mode 100644 index 0000000..90dc70d --- /dev/null +++ b/src/pages/enterprise/mod.rs @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is not open source software. It must not be modified or distributed without + * explicit permission from Stalwart Labs Ltd. + * Unauthorized use, modification, or distribution is strictly prohibited. + */ + +pub mod undelete; diff --git a/src/pages/enterprise/undelete.rs b/src/pages/enterprise/undelete.rs new file mode 100644 index 0000000..6a89e08 --- /dev/null +++ b/src/pages/enterprise/undelete.rs @@ -0,0 +1,506 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is not open source software. It must not be modified or distributed without + * explicit permission from Stalwart Labs Ltd. + * Unauthorized use, modification, or distribution is strictly prohibited. + */ + +use std::{collections::HashSet, sync::Arc}; + +use chrono::{DateTime, Utc}; +use chrono_humanize::HumanTime; +use humansize::{format_size, DECIMAL}; +use leptos::*; +use leptos_router::*; +use serde::{Deserialize, Serialize}; + +use crate::{ + components::{ + form::button::Button, + icon::{IconArrowUTurnLeft, IconEnvelope}, + list::{ + header::ColumnList, pagination::Pagination, row::SelectItem, toolbar::ToolbarButton, + Footer, ListItem, ListSection, ListTable, Toolbar, ZeroResults, + }, + messages::{ + alert::{use_alerts, Alert}, + modal::{use_modals, Modal}, + }, + skeleton::Skeleton, + Color, + }, + core::{ + http::{self, HttpRequest}, + oauth::use_authorization, + url::UrlBuilder, + }, + pages::{maybe_plural, FormatDateTime, List}, +}; + +const PAGE_SIZE: u32 = 20; + +#[derive(Clone, Serialize, Deserialize, Default)] +struct DeletedBlob { + pub hash: String, + pub size: usize, + #[serde(rename = "deletedAt")] + pub deleted_at: DateTime, + #[serde(rename = "expiresAt")] + pub expires_at: DateTime, + pub collection: String, +} + +#[derive(Clone, Serialize, Deserialize)] +struct UndeleteRequest { + hash: String, + collection: String, + #[serde(rename = "restoreTime")] + time: DateTime, + #[serde(rename = "cancelDeletion")] + #[serde(default)] + cancel_deletion: DateTime, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +enum UndeleteResponse { + Success, + NotFound, + Error { reason: String }, +} + +#[component] +pub fn UndeleteList() -> impl IntoView { + let query = use_query_map(); + let params = use_params_map(); + let page = create_memo(move |_| { + query + .with(|q| q.get("page").and_then(|page| page.parse::().ok())) + .filter(|&page| page > 0) + .unwrap_or(1) + }); + + let auth = use_authorization(); + let alert = use_alerts(); + let modal = use_modals(); + let selected = create_rw_signal::>(HashSet::new()); + provide_context(selected); + + let blobs = create_resource( + move || { + ( + page.get(), + params.get().get("id").cloned().unwrap_or_default(), + ) + }, + move |(page, account)| { + let auth = auth.get_untracked(); + + async move { + HttpRequest::get(("/api/pro/undelete", account)) + .with_authorization(&auth) + .with_parameter("page", page.to_string()) + .with_parameter("limit", PAGE_SIZE.to_string()) + .send::>() + .await + .map(Arc::new) + } + }, + ); + let results = create_rw_signal(Arc::new(List::default())); + let blob_hash = RwSignal::new(String::new()); + let fetch_headers = RwSignal::new(true); + let fetch_contents = create_resource( + move || (blob_hash.get(), fetch_headers.get()), + move |(blob_hash, fetch_headers)| { + let auth = auth.get_untracked(); + let blob_hash = blob_hash.clone(); + + async move { + if !blob_hash.is_empty() { + HttpRequest::get(("/api/store/blobs", &blob_hash)) + .with_optional_parameter("limit", fetch_headers.then_some("10240")) + .with_authorization(&auth) + .send_raw() + .await + .map(|bytes| { + let contents = if fetch_headers { + let mut contents = Vec::with_capacity(bytes.len()); + for byte in bytes { + match byte { + b'\n' + if contents.last().copied().unwrap_or_default() + == b'\n' => + { + break; + } + b'\r' => { + continue; + } + _ => {} + } + contents.push(byte); + } + contents + } else { + bytes + }; + + Some(String::from_utf8(contents).unwrap_or_else(|e| { + String::from_utf8_lossy(e.as_bytes()).into_owned() + })) + }) + } else { + Ok(None) + } + } + }, + ); + + let restore_action = create_action(move |items: &Arc>| { + let items = items.clone(); + let account = params.get().get("id").cloned().unwrap_or_default(); + let auth = auth.get(); + let results = results.get_untracked(); + + async move { + let mut request = Vec::with_capacity(items.len()); + for item in items.iter() { + if let Some(blob) = results + .items + .iter() + .find(|b: &&DeletedBlob| &b.hash == item) + { + request.push(UndeleteRequest { + hash: blob.hash.clone(), + collection: blob.collection.clone(), + time: blob.deleted_at, + cancel_deletion: blob.expires_at, + }); + } + } + + match HttpRequest::post(("/api/pro/undelete", account)) + .with_authorization(&auth) + .with_body(request) + .unwrap() + .send::>() + .await + { + Ok(responses) => { + blobs.refetch(); + let mut success = 0; + let mut not_found = 0; + let mut errors = Vec::new(); + + for response in responses { + match response { + UndeleteResponse::Success => { + success += 1; + } + UndeleteResponse::NotFound => { + not_found += 1; + } + UndeleteResponse::Error { reason } => { + errors.push(reason); + } + } + } + + match (success, not_found, errors.len()) { + (_, 0, 0) => { + alert.set(Alert::success(format!( + "Restored {}.", + maybe_plural(success, "blob", "blobs") + ))); + } + (_, _, 0) => { + alert.set(Alert::warning(format!( + "Restored {} and {} could not be found.", + maybe_plural(success, "blob", "blobs"), + maybe_plural(not_found, "blob", "blobs") + ))); + } + _ => { + alert.set( + Alert::warning(format!( + "Restored {}, {} could not be found. Errors were found:", + maybe_plural(success, "blob", "blobs"), + maybe_plural(not_found, "blob", "blobs") + )) + .with_details_list(errors), + ); + } + } + } + Err(err) => { + alert.set(Alert::from(err)); + } + } + } + }); + + view! { + + + + + 0 { + format!("Restore ({ns})") + } else { + "Restore".to_string() + } + }) + + color=Color::Red + on_click=Callback::new(move |_| { + let to_delete = selected.get().len(); + if to_delete > 0 { + let text = maybe_plural(to_delete, "blob", "blobs"); + modal + .set( + Modal::with_title("Confirm restore") + .with_message( + format!( + "Are you sure you want to restore {text}? This action cannot be undone.", + ), + ) + .with_button(format!("Restore {text}")) + .with_dangerous_callback(move || { + restore_action + .dispatch( + Arc::new( + selected.try_update(std::mem::take).unwrap_or_default(), + ), + ); + }), + ) + } + }) + > + + + + + + + + {move || match blobs.get() { + None => None, + Some(Err(http::Error::Unauthorized)) => { + use_navigate()("/login", Default::default()); + Some(view! {
}.into_view()) + } + Some(Err(err)) => { + results.set(Arc::new(List::default())); + alert.set(Alert::from(err)); + Some(view! { }.into_view()) + } + Some(Ok(blobs)) if !blobs.items.is_empty() => { + results.set(blobs.clone()); + let blobs_ = blobs.clone(); + Some( + view! { + >() + }) + > + + + + + + + } + .into_view(), + ) + } + Some(Ok(_)) => { + results.set(Arc::new(List::default())); + Some( + view! { + + } + .into_view(), + ) + } + }} + +
+ +
+ + + +
+
+
+
+ + + + {move || match fetch_contents.get() { + None | Some(Ok(None)) | Some(Err(http::Error::NotFound)) => None, + Some(Err(http::Error::Unauthorized)) => { + use_navigate()("/login", Default::default()); + Some(view! {
}.into_view()) + } + Some(Err(err)) => { + alert.set(Alert::from(err)); + Some(view! {
}.into_view()) + } + Some(Ok(Some(message))) => { + Some( + view! { +
+
+
+
+

+ {move || { + if fetch_headers.get() { + "View Headers" + } else { + "View Contents" + } + }} + +

+ +
+
+ + + + + +
+
+ +
+ {message} +
+ +
+
+ } + .into_view(), + ) + } + }} + +
+ } +} + +#[component] +fn UndeleteItem(blob: DeletedBlob, blob_hash: RwSignal) -> impl IntoView { + let blob_id = blob.hash.clone(); + + view! { + + + + + + + {blob.collection} + + + + {format_size(blob.size, DECIMAL)} + + + + {blob.deleted_at.format_date_time()} + + + + + {HumanTime::from(blob.expires_at).to_string()} + + + + + + + View + + + + } +} diff --git a/src/pages/login.rs b/src/pages/login.rs index d743ee2..cce03ec 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -59,6 +59,7 @@ pub fn Login() -> impl IntoView { auth_token.username = username.into(); auth_token.is_valid = true; auth_token.is_admin = response.is_admin; + auth_token.is_enterprise = response.is_enterprise; if let Err(err) = SessionStorage::set(STATE_STORAGE_KEY, auth_token.clone()) @@ -237,6 +238,7 @@ pub fn Login() -> impl IntoView { }); } > + Sign in diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 07d8432..ad576b3 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -11,6 +11,7 @@ pub mod account; pub mod authorize; pub mod config; pub mod directory; +pub mod enterprise; pub mod login; pub mod manage; pub mod notfound; diff --git a/style/output.css b/style/output.css index 8921662..db12ab5 100644 --- a/style/output.css +++ b/style/output.css @@ -801,6 +801,10 @@ select { inset-inline-end: 0px; } +.right-0 { + right: 0px; +} + .start-0 { inset-inline-start: 0px; } @@ -1102,6 +1106,10 @@ select { width: 100%; } +.min-w-40 { + min-width: 10rem; +} + .min-w-8 { min-width: 2rem; } @@ -1186,6 +1194,10 @@ select { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } +.cursor-not-allowed { + cursor: not-allowed; +} + .cursor-pointer { cursor: pointer; } @@ -1898,6 +1910,14 @@ select { opacity: 0; } +.opacity-100 { + opacity: 1; +} + +.opacity-50 { + opacity: 0.5; +} + .opacity-70 { opacity: 0.7; } @@ -1908,6 +1928,12 @@ select { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.shadow-2xl { + --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); + --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + .shadow-md { --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); @@ -1943,6 +1969,12 @@ select { transition-duration: 150ms; } +.transition-\[opacity\2c margin\] { + transition-property: opacity,margin; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + .transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); @@ -1977,6 +2009,10 @@ select { transition-timing-function: cubic-bezier(0, 0, 0.2, 1); } +.\[--placement\:bottom-right\] { + --placement: bottom-right; +} + .\[--trigger\:hover\] { --trigger: hover; } @@ -2288,6 +2324,10 @@ select { --tw-ring-offset-color: #f0fdfa; } +.focus\:ring-offset-white:focus { + --tw-ring-offset-color: #fff; +} + .focus\:ring-offset-yellow-50:focus { --tw-ring-offset-color: #fefce8; } @@ -2310,6 +2350,14 @@ select { color: rgb(37 99 235 / var(--tw-text-opacity)); } +.hs-dropdown.open > .hs-dropdown-open\:opacity-100 { + opacity: 1; +} + +.hs-dropdown.open > .hs-dropdown-menu > .hs-dropdown-open\:opacity-100 { + opacity: 1; +} + .hs-tooltip.show .hs-tooltip-shown\:visible { visibility: visible; } @@ -2408,6 +2456,15 @@ select { border-color: rgb(55 65 81 / var(--tw-divide-opacity)); } +:is(.dark .dark\:divide-neutral-700) > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgb(64 64 64 / var(--tw-divide-opacity)); +} + +:is(.dark .dark\:border) { + border-width: 1px; +} + :is(.dark .dark\:border-gray-600) { --tw-border-opacity: 1; border-color: rgb(75 85 99 / var(--tw-border-opacity)); @@ -2423,6 +2480,11 @@ select { border-color: rgb(31 41 55 / var(--tw-border-opacity)); } +:is(.dark .dark\:border-neutral-700) { + --tw-border-opacity: 1; + border-color: rgb(64 64 64 / var(--tw-border-opacity)); +} + :is(.dark .dark\:border-red-900) { --tw-border-opacity: 1; border-color: rgb(127 29 29 / var(--tw-border-opacity)); @@ -2461,6 +2523,11 @@ select { background-color: rgb(17 24 39 / var(--tw-bg-opacity)); } +:is(.dark .dark\:bg-neutral-800) { + --tw-bg-opacity: 1; + background-color: rgb(38 38 38 / var(--tw-bg-opacity)); +} + :is(.dark .dark\:bg-red-500\/10) { background-color: rgb(239 68 68 / 0.1); } @@ -2542,6 +2609,11 @@ select { color: rgb(75 85 99 / var(--tw-text-opacity)); } +:is(.dark .dark\:text-neutral-400) { + --tw-text-opacity: 1; + color: rgb(163 163 163 / var(--tw-text-opacity)); +} + :is(.dark .dark\:text-neutral-600) { --tw-text-opacity: 1; color: rgb(82 82 82 / var(--tw-text-opacity)); @@ -2644,6 +2716,11 @@ select { background-color: rgb(17 24 39 / var(--tw-bg-opacity)); } +:is(.dark .dark\:hover\:bg-neutral-700:hover) { + --tw-bg-opacity: 1; + background-color: rgb(64 64 64 / var(--tw-bg-opacity)); +} + :is(.dark .dark\:hover\:bg-red-800\/50:hover) { background-color: rgb(153 27 27 / 0.5); } @@ -2675,6 +2752,11 @@ select { color: rgb(96 165 250 / var(--tw-text-opacity)); } +:is(.dark .dark\:hover\:text-neutral-300:hover) { + --tw-text-opacity: 1; + color: rgb(212 212 212 / var(--tw-text-opacity)); +} + :is(.dark .dark\:hover\:text-slate-300:hover) { --tw-text-opacity: 1; color: rgb(203 213 225 / var(--tw-text-opacity));