diff --git a/src/main.rs b/src/main.rs index 77e9b9e..aa8f6cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,12 @@ use pages::{ mfa::ManageMfa, }, config::edit::DEFAULT_SETTINGS_URL, - directory::{dns::DnsDisplay, edit::PrincipalEdit, list::PrincipalList}, + directory::{ + dns::DnsDisplay, + edit::PrincipalEdit, + forward::{edit::ForwardEdit, list::ForwardList}, + list::PrincipalList, + }, manage::{ spam::{SpamTest, SpamTrain}, troubleshoot::{TroubleshootDelivery, TroubleshootDmarc}, @@ -76,9 +81,6 @@ pub const STATE_STORAGE_KEY: &str = "webadmin_state"; pub const STATE_LOGIN_NAME_KEY: &str = "webadmin_login_name"; fn main() { - core::schema::print_schemas(&build_schemas()); - return; - _ = console_log::init_with_level(log::Level::Debug); console_error_panic_hook::set_once(); leptos::mount_to_body(|| view! { }) @@ -238,6 +240,28 @@ pub fn App() -> impl IntoView { } /> + + + + }) @@ -708,6 +736,9 @@ impl LayoutBuilder { .create("Accounts") .route("/directory/accounts") .insert(permissions.has_access(Permission::IndividualList)) + .create("Forwards") + .route("/directory/forwards") + .insert(permissions.has_access(Permission::SettingsList)) .create("Groups") .route("/directory/groups") .insert(permissions.has_access(Permission::GroupList)) @@ -735,6 +766,7 @@ impl LayoutBuilder { Permission::MailingListList, Permission::OauthClientList, Permission::ApiKeyList, + Permission::SettingsList, ])) .create("Queues") .icon(view! { }) diff --git a/src/pages/config/mod.rs b/src/pages/config/mod.rs index de4887e..2f8305b 100644 --- a/src/pages/config/mod.rs +++ b/src/pages/config/mod.rs @@ -15,9 +15,9 @@ use crate::{ components::{ form::input::{Duration, Rate}, icon::{ - IconCalendarDays, IconCircleStack, IconCodeBracket, IconHandRaised, IconInbox, - IconInboxArrowDown, IconInboxStack, IconKey, IconServer, IconServerStack, - IconShieldCheck, IconSignal, + IconArrowRightCircle, IconCalendarDays, IconCircleStack, IconCodeBracket, + IconHandRaised, IconInbox, IconInboxArrowDown, IconInboxStack, IconKey, IconServer, + IconServerStack, IconShieldCheck, IconSignal, }, layout::{LayoutBuilder, MenuItem}, }, diff --git a/src/pages/directory/edit.rs b/src/pages/directory/edit.rs index 355f5d8..6acd4b7 100644 --- a/src/pages/directory/edit.rs +++ b/src/pages/directory/edit.rs @@ -44,7 +44,17 @@ use crate::{ }, }; -use super::{build_app_password, parse_app_password, SpecialSecrets}; +use crate::pages::config::UpdateSettings; + +use super::{ + build_app_password, + forward::{ + forward_prefix, list::{parse_forward_settings, rebuild_sieve_for_domains, SettingsList}, + AccountForwardSection, ForwardRule, + }, + parse_app_password, + SpecialSecrets, +}; type PrincipalMap = AHashMap>; @@ -95,6 +105,47 @@ pub fn PrincipalEdit() -> impl IntoView { let data = expect_context::>() .build_form("principals") .into_signal(); + let primary_email = Signal::derive(move || { + data.get().value::("email").unwrap_or_default() + }); + + let forward_to: RwSignal> = create_rw_signal(Vec::new()); + let forward_keep_copy: RwSignal = create_rw_signal(false); + + // Load forward rule whenever the account email changes + let load_forward = create_resource( + move || primary_email.get(), + move |addr| { + let auth = auth.get(); + async move { + if addr.is_empty() || !addr.contains('@') { + return Ok(ForwardRule::default()); + } + let raw = HttpRequest::get("/api/settings/list") + .with_parameter("prefix", format!("mail.forward.{addr}")) + .with_authorization(&auth) + .send::() + .await?; + let mut rule = ForwardRule { from: addr, keep_copy: false, to: Vec::new() }; + for (key, value) in &raw.items { + if key.starts_with("to.") { + rule.to.push(value.clone()); + } else if key == "keep-copy" { + rule.keep_copy = value == "true"; + } + } + rule.to.sort(); + Ok::(rule) + } + }, + ); + create_effect(move |_| { + if let Some(Ok(rule)) = load_forward.get() { + forward_to.set(rule.to); + forward_keep_copy.set(rule.keep_copy); + } + }); + let fetch_principal = create_resource( move || params.get().get("id").cloned().unwrap_or_default(), move |name| { @@ -247,6 +298,9 @@ pub fn PrincipalEdit() -> impl IntoView { let changes = changes.clone(); let auth = auth.get(); let selected_type = selected_type.get(); + let fwd_to = forward_to.get(); + let fwd_keep = forward_keep_copy.get(); + let fwd_email = primary_email.get(); async move { set_pending.set(true); @@ -297,9 +351,62 @@ pub fn PrincipalEdit() -> impl IntoView { result }; + // Also save forwarding rule for Individual accounts + let fwd_result = if selected_type == PrincipalType::Individual + && result.is_ok() + && !fwd_email.is_empty() + && fwd_email.contains('@') + { + use std::collections::HashSet; + let domain = fwd_email.splitn(2, '@').nth(1).unwrap_or("").to_string(); + let affected = HashSet::from([domain]); + let mut updates: Vec = vec![UpdateSettings::Clear { + prefix: format!("{}.", forward_prefix(&fwd_email)), + filter: None, + }]; + let to_addrs: Vec = fwd_to + .iter() + .map(|s| s.trim().to_lowercase()) + .filter(|s| s.contains('@')) + .collect(); + if !to_addrs.is_empty() { + let mut values: Vec<(String, String)> = to_addrs + .iter() + .enumerate() + .map(|(i, addr)| (format!("to.{i}"), addr.clone())) + .collect(); + values.push(("keep-copy".to_string(), fwd_keep.to_string())); + updates.push(UpdateSettings::Insert { + prefix: Some(forward_prefix(&fwd_email)), + values, + assert_empty: false, + }); + } + let fwd_save = HttpRequest::post("/api/settings") + .with_authorization(&auth) + .with_body(updates) + .unwrap() + .send::>() + .await; + if fwd_save.is_ok() { + if let Ok(all_raw) = HttpRequest::get("/api/settings/list") + .with_parameter("prefix", "mail.forward") + .with_authorization(&auth) + .send::() + .await + { + let all_rules = parse_forward_settings(&all_raw.items); + let _ = rebuild_sieve_for_domains(&auth, &affected, &all_rules).await; + } + } + fwd_save.map(|_| ()) + } else { + Ok(()) + }; + set_pending.set(false); - match result { + match result.and(fwd_result) { Ok(_) => { use_navigate()( &format!("/manage/directory/{}", selected_type.resource_name()), @@ -421,6 +528,8 @@ pub fn PrincipalEdit() -> impl IntoView { | PrincipalType::ApiKey ) .then_some("Permissions".to_string()), + matches!(typ, PrincipalType::Individual) + .then_some("Forwarding".to_string()), ] })> @@ -1064,6 +1173,11 @@ pub fn PrincipalEdit() -> impl IntoView { + + } .into_view(), diff --git a/src/pages/directory/forward/edit.rs b/src/pages/directory/forward/edit.rs new file mode 100644 index 0000000..c35a3b5 --- /dev/null +++ b/src/pages/directory/forward/edit.rs @@ -0,0 +1,270 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::collections::HashSet; + +use leptos::*; +use leptos_router::*; + +use serde::Deserialize; + +use crate::{ + components::{ + form::{button::Button, Form, FormButtonBar, FormItem, FormSection}, + messages::alert::{use_alerts, Alert}, + Color, + }, + core::{ + http::{self, HttpRequest}, + oauth::use_authorization, + }, + pages::config::{Settings, UpdateSettings}, +}; + +#[derive(Deserialize, Default)] +struct SettingsList { + pub items: Settings, +} + +use super::{ + forward_prefix, + list::{parse_forward_settings, rebuild_sieve_for_domains}, + ForwardRule, +}; + +#[component] +pub fn ForwardEdit() -> impl IntoView { + let auth = use_authorization(); + let alert = use_alerts(); + let params = use_params_map(); + + let is_create = create_memo(move |_| { + params.get().get("id").map(|id| id == "_new_").unwrap_or(true) + }); + + let from_param = create_memo(move |_| { + if is_create.get() { + String::new() + } else { + params + .get() + .get("id") + .map(|id| id.replace("%40", "@").replace("%2B", "+")) + .unwrap_or_default() + } + }); + + let from = create_rw_signal(String::new()); + let to_input = create_rw_signal(String::new()); // comma/newline separated in textarea + let keep_copy = create_rw_signal(true); + + // Load existing rule when editing + let _load = create_resource( + move || from_param.get(), + move |addr| { + let auth = auth.get(); + async move { + if addr.is_empty() { + return Ok(ForwardRule::default()); + } + let raw = HttpRequest::get("/api/settings/list") + .with_parameter("prefix", format!("mail.forward.{addr}")) + .with_authorization(&auth) + .send::() + .await?; + + let mut rule = ForwardRule { + from: addr.clone(), + keep_copy: true, + to: Vec::new(), + }; + for (key, value) in &raw.items { + if key.starts_with("to.") { + rule.to.push(value.clone()); + } else if key == "keep-copy" { + rule.keep_copy = value == "true"; + } + } + rule.to.sort(); + Ok::(rule) + } + }, + ); + + create_effect(move |_| { + if let Some(Ok(rule)) = _load.get() { + from.set(rule.from.clone()); + to_input.set(rule.to.join("\n")); + keep_copy.set(rule.keep_copy); + } + }); + + let save_action = create_action(move |_: &()| { + let auth = auth.get(); + let from_val = from.get().trim().to_lowercase(); + let to_val: Vec = to_input + .get() + .split([',', '\n', ';']) + .map(|s| s.trim().to_lowercase()) + .filter(|s| s.contains('@')) + .collect(); + let keep = keep_copy.get(); + let old_from = from_param.get(); + let creating = is_create.get(); + + async move { + if from_val.is_empty() || !from_val.contains('@') { + alert.set(Alert::error("From address must be a valid email address.")); + return Ok(()); + } + if to_val.is_empty() { + alert.set(Alert::error( + "At least one valid destination address is required.", + )); + return Ok(()); + } + + let mut updates: Vec = Vec::new(); + let mut affected: HashSet = HashSet::new(); + + if !creating && old_from != from_val { + let old_domain = old_from.splitn(2, '@').nth(1).unwrap_or("").to_string(); + affected.insert(old_domain); + updates.push(UpdateSettings::Clear { + prefix: format!("{}.", forward_prefix(&old_from)), + filter: None, + }); + } + + // Clear existing entries for this address + updates.push(UpdateSettings::Clear { + prefix: format!("{}.", forward_prefix(&from_val)), + filter: None, + }); + + // Insert new values + let mut values: Vec<(String, String)> = to_val + .iter() + .enumerate() + .map(|(i, addr)| (format!("to.{i}"), addr.clone())) + .collect(); + values.push(("keep-copy".to_string(), keep.to_string())); + + updates.push(UpdateSettings::Insert { + prefix: Some(forward_prefix(&from_val)), + values, + assert_empty: false, + }); + + HttpRequest::post("/api/settings") + .with_authorization(&auth) + .with_body(updates)? + .send::>() + .await?; + + // Rebuild Sieve scripts for all affected domains + let new_domain = from_val.splitn(2, '@').nth(1).unwrap_or("").to_string(); + affected.insert(new_domain); + + let all_raw = HttpRequest::get("/api/settings/list") + .with_parameter("prefix", "mail.forward") + .with_authorization(&auth) + .send::() + .await?; + let all_rules = parse_forward_settings(&all_raw.items); + + rebuild_sieve_for_domains(&auth, &affected, &all_rules).await?; + + Ok::<(), http::Error>(()) + } + }); + + create_effect(move |_| { + if let Some(result) = save_action.value().get() { + match result { + Ok(_) => { + alert.set(Alert::success("Forward saved. Sieve script updated.")); + use_navigate()("/manage/directory/forwards", Default::default()); + } + Err(e) => alert.set(Alert::from(e)), + } + } + }); + + view! { + + + + + + + + + + + + + + + "Deliver a copy to the local mailbox" + + + + + + + + + + + } +} diff --git a/src/pages/directory/forward/list.rs b/src/pages/directory/forward/list.rs new file mode 100644 index 0000000..1023b0a --- /dev/null +++ b/src/pages/directory/forward/list.rs @@ -0,0 +1,425 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{collections::HashSet, sync::Arc}; + +use leptos::*; +use leptos_router::*; + +use crate::{ + components::{ + icon::{IconAdd, IconTrash}, + list::{ + header::ColumnList, + pagination::Pagination, + row::SelectItem, + toolbar::{SearchBox, ToolbarButton}, + Footer, ItemSelection, ListItem, ListSection, ListTable, ListTextItem, Toolbar, + ZeroResults, + }, + messages::{ + alert::{use_alerts, Alert}, + modal::{use_modals, Modal}, + }, + skeleton::Skeleton, + Color, + }, + core::{ + http::{self, HttpRequest}, + oauth::use_authorization, + url::UrlBuilder, + AccessToken, + }, + pages::maybe_plural, +}; + +use crate::pages::config::{Settings, UpdateSettings}; + +use super::{build_sieve_script, domain_script_id, ForwardRule}; + +const PAGE_SIZE: u32 = 10; + +#[derive(serde::Deserialize, Default)] +pub struct SettingsList { + pub items: Settings, +} + +pub async fn fetch_all_forwards(auth: &AccessToken) -> Result, http::Error> { + let raw = HttpRequest::get("/api/settings/list") + .with_parameter("prefix", "mail.forward") + .with_authorization(auth) + .send::() + .await?; + Ok(parse_forward_settings(&raw.items)) +} + +/// Parse a flat settings map (relative to prefix `mail.forward`) into ForwardRules. +pub fn parse_forward_settings(raw: &Settings) -> Vec { + use std::collections::BTreeMap; + let mut map: BTreeMap = BTreeMap::new(); + + for (key, value) in raw { + // keys are relative: "." + // field is either "to.N" or "keep-copy"; split on last occurrence + let (from, rest) = if let Some(pos) = key.rfind(".to.") { + (&key[..pos], &key[pos + 1..]) + } else if let Some(stripped) = key.strip_suffix(".keep-copy") { + (stripped, "keep-copy") + } else { + continue; + }; + let from = from.to_string(); + + let rule = map.entry(from.clone()).or_insert_with(|| ForwardRule { + from: from.clone(), + keep_copy: true, + ..Default::default() + }); + + if rest.starts_with("to.") { + rule.to.push(value.clone()); + } else if rest == "keep-copy" { + rule.keep_copy = value == "true"; + } + } + + let mut rules: Vec = map.into_values().collect(); + for r in &mut rules { + r.to.sort(); + } + rules +} + +/// Regenerate (or delete) Sieve scripts for each affected domain. +pub async fn rebuild_sieve_for_domains( + auth: &AccessToken, + domains: &HashSet, + all_rules: &[ForwardRule], +) -> Result<(), http::Error> { + let mut updates: Vec = Vec::new(); + + for domain in domains { + let script_id = domain_script_id(domain); + let script_key = format!("{script_id}.contents"); + let content = build_sieve_script(domain, all_rules); + + if content.is_empty() { + updates.push(UpdateSettings::Delete { + keys: vec![format!("sieve.trusted.scripts.{script_key}")], + }); + } else { + updates.push(UpdateSettings::Insert { + prefix: Some("sieve.trusted.scripts".to_string()), + values: vec![(script_key, content)], + assert_empty: false, + }); + } + } + + if !updates.is_empty() { + HttpRequest::post("/api/settings") + .with_authorization(auth) + .with_body(updates)? + .send::>() + .await?; + } + + Ok(()) +} + +#[component] +pub fn ForwardList() -> impl IntoView { + let auth = use_authorization(); + let alert = use_alerts(); + let modal = use_modals(); + let selected = create_rw_signal::(ItemSelection::None); + provide_context(selected); + + let query = use_query_map(); + let page = create_memo(move |_| { + query + .with(|q| q.get("page").and_then(|p| p.parse::().ok())) + .filter(|&p| p > 0) + .unwrap_or(1) + }); + let filter = create_memo(move |_| { + query.with(|q| { + q.get("filter").and_then(|s| { + let s = s.trim(); + if !s.is_empty() { + Some(s.to_string()) + } else { + None + } + }) + }) + }); + + let forwards = create_resource( + move || (page.get(), filter.get()), + move |_| { + let auth = auth.get(); + async move { fetch_all_forwards(&auth).await } + }, + ); + + let total_results = create_rw_signal(None::); + + let delete_selected = create_action(move |items: &Arc>| { + let items = items.clone(); + let auth = auth.get(); + async move { + let all_rules = fetch_all_forwards(&auth).await?; + let mut updates: Vec = Vec::new(); + let mut affected: HashSet = HashSet::new(); + + for from in items.iter() { + affected.insert(from.splitn(2, '@').nth(1).unwrap_or("").to_string()); + updates.push(UpdateSettings::Clear { + prefix: format!("mail.forward.{from}."), + filter: None, + }); + } + + HttpRequest::post("/api/settings") + .with_authorization(&auth) + .with_body(updates) + ? + .send::>() + .await?; + + let remaining: Vec = + all_rules.into_iter().filter(|r| !items.contains(&r.from)).collect(); + rebuild_sieve_for_domains(&auth, &affected, &remaining).await + } + }); + + let delete_all = create_action(move |_: &()| { + let auth = auth.get(); + async move { + let all_rules = fetch_all_forwards(&auth).await?; + let domains: HashSet = + all_rules.iter().map(|r| r.domain().to_string()).collect(); + + HttpRequest::post("/api/settings") + .with_authorization(&auth) + .with_body(vec![UpdateSettings::Clear { + prefix: "mail.forward.".to_string(), + filter: None, + }])? + .send::>() + .await?; + + rebuild_sieve_for_domains(&auth, &domains, &[]).await + } + }); + + create_effect(move |_| { + if let Some(result) = delete_selected.value().get() { + match result { + Ok(_) => { + forwards.refetch(); + alert.set(Alert::success("Forward deleted. Sieve script updated.")); + } + Err(e) => alert.set(Alert::from(e)), + } + } + }); + + create_effect(move |_| { + if let Some(result) = delete_all.value().get() { + match result { + Ok(_) => { + forwards.refetch(); + alert.set(Alert::success("All forwards deleted.")); + } + Err(e) => alert.set(Alert::from(e)), + } + } + }); + + view! { + + + + + + + + 0 { + format!("Delete {}", maybe_plural(n, "forward", "forwards")) + } else { + "Delete".to_string() + } + }) + color=Color::Red + on_click=Callback::new(move |_| { + let to_delete = selected.get(); + modal.set( + Modal::with_title("Confirm deletion") + .with_message( + "Are you sure? The associated Sieve script will be updated.", + ) + .with_button("Delete") + .with_dangerous_callback(move || match &to_delete { + ItemSelection::All => { + delete_all.dispatch(()); + } + ItemSelection::Some(items) => { + delete_selected + .dispatch(Arc::new(items.iter().cloned().collect())); + } + ItemSelection::None => {} + }), + ); + }) + > + + + + + + {move || match forwards.get() { + None => None, + Some(Err(http::Error::Unauthorized)) => { + use_navigate()("/login", Default::default()); + None + } + Some(Err(err)) => { + total_results.set(Some(0)); + alert.set(Alert::from(err)); + Some(view! { }.into_view()) + } + Some(Ok(mut rules)) => { + if let Some(f) = filter.get() { + let f_lower = f.to_lowercase(); + rules.retain(|r| { + r.from.to_lowercase().contains(&f_lower) + || r.to.iter().any(|t| t.to_lowercase().contains(&f_lower)) + }); + } + let total = rules.len() as u32; + total_results.set(Some(total)); + + if total == 0 { + return Some(view! { + + }.into_view()); + } + + let start = ((page.get() - 1) * PAGE_SIZE) as usize; + let page_rules: Vec<_> = rules + .into_iter() + .skip(start) + .take(PAGE_SIZE as usize) + .collect(); + + Some(view! { + + + + + + + + {from} + + + {to_display} + {keep} + + } + } + /> + + }.into_view()) + } + }} + + + + + + + } +} diff --git a/src/pages/directory/forward/mod.rs b/src/pages/directory/forward/mod.rs new file mode 100644 index 0000000..c4c8261 --- /dev/null +++ b/src/pages/directory/forward/mod.rs @@ -0,0 +1,174 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod edit; +pub mod list; + +use leptos::*; +use serde::{Deserialize, Serialize}; + +use crate::components::{ + form::{FormItem, FormSection}, + icon::{IconPlus, IconXMark}, +}; + +/// A single email forwarding rule. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForwardRule { + /// Source address (e.g. `alice@example.com`). + pub from: String, + /// One or more destination addresses. + pub to: Vec, + /// When true a copy is also delivered to the local mailbox. + pub keep_copy: bool, +} + +impl ForwardRule { + /// Domain part of the source address (e.g. `example.com`). + pub fn domain(&self) -> &str { + self.from.splitn(2, '@').nth(1).unwrap_or("") + } +} + +/// Canonical settings key prefix for a forward rule. +pub fn forward_prefix(from: &str) -> String { + format!("mail.forward.{from}") +} + +/// Generate a per-domain Sieve script from a list of rules. +/// +/// Rules belonging to other domains are silently ignored, so callers +/// can pass the full list and this function handles the filtering. +pub fn build_sieve_script(domain: &str, rules: &[ForwardRule]) -> String { + let domain_rules: Vec<&ForwardRule> = rules + .iter() + .filter(|r| r.domain() == domain && !r.to.is_empty()) + .collect(); + + if domain_rules.is_empty() { + return String::new(); + } + + let mut s = String::from("require [\"redirect\", \"envelope\", \"copy\"];\n\n"); + + for rule in domain_rules { + s.push_str(&format!( + "if envelope :is \"to\" \"{}\" {{\n", + rule.from + )); + for dest in &rule.to { + if rule.keep_copy { + s.push_str(&format!(" redirect :copy \"{dest}\";\n")); + } else { + s.push_str(&format!(" redirect \"{dest}\";\n")); + } + } + s.push_str("}\n\n"); + } + + s +} + +/// Sieve script settings key for a domain (underscores replace dots). +pub fn domain_script_id(domain: &str) -> String { + domain.replace('.', "_") +} + +/// Inline forwarding section for the account edit page. +/// Props are owned by `PrincipalEdit`; saving is done via the main Save button. +#[component] +pub fn AccountForwardSection( + forward_to: RwSignal>, + forward_keep_copy: RwSignal, +) -> impl IntoView { + view! { + + + + >() + } + key=move |(idx, item)| { + format!( + "{idx}_{}", + item.as_bytes().iter().map(|v| *v as usize).sum::(), + ) + } + children=move |(idx, addr)| { + view! { + + + + + + + } + } + /> + + + + + "Add address" + + + + + + + + + "Deliver a copy to the local mailbox" + + + + + } +} diff --git a/src/pages/directory/mod.rs b/src/pages/directory/mod.rs index dde3edc..155b2c2 100644 --- a/src/pages/directory/mod.rs +++ b/src/pages/directory/mod.rs @@ -15,6 +15,7 @@ use base64::{engine::general_purpose::STANDARD, Engine}; pub mod dns; pub mod edit; +pub mod forward; pub mod list; #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/style/output.css b/style/output.css index a3a13a8..9a9aa4b 100644 --- a/style/output.css +++ b/style/output.css @@ -1148,6 +1148,10 @@ select { width: 2.75rem; } +.w-4 { + width: 1rem; +} + .w-40 { width: 10rem; } @@ -1920,6 +1924,10 @@ select { text-transform: uppercase; } +.lowercase { + text-transform: lowercase; +} + .leading-none { line-height: 1; } @@ -2835,6 +2843,11 @@ select { --tw-bg-opacity: 0.8; } +:is(.dark .dark\:text-blue-400) { + --tw-text-opacity: 1; + color: rgb(96 165 250 / var(--tw-text-opacity)); +} + :is(.dark .dark\:text-blue-500) { --tw-text-opacity: 1; color: rgb(59 130 246 / var(--tw-text-opacity));
+ + + "Add address" + +