Skip to main content

yggdrasil/pages/admin/settings/
security_section.rs

1//! 安全配置分区(CSRF 可信源、cookie Secure、代理层数、并发会话上限)。
2//!
3//! 即时生效层:面板保存后失效 moka 缓存,数秒内全链路生效(CSRF 校验、
4//! cookie 构造、真实 IP 提取、登录会话淘汰全部走 runtime accessor)。
5
6use dioxus::prelude::*;
7
8#[cfg(target_arch = "wasm32")]
9use crate::api::settings::{get_security_settings, update_security_settings};
10#[cfg(target_arch = "wasm32")]
11use crate::components::forms::{FormInput, FormLabel, FormSelect};
12#[cfg(target_arch = "wasm32")]
13use crate::components::ui::{Checkbox, LoadingButton, ADMIN_CARD_CLASS};
14#[cfg(target_arch = "wasm32")]
15use crate::models::settings::SecuritySettings;
16
17/// 安全配置分区组件。
18#[allow(non_snake_case)]
19#[component]
20pub fn SecuritySection(toast: Callback<(String, bool)>) -> Element {
21    #[cfg(target_arch = "wasm32")]
22    {
23        let mut saved: Signal<SecuritySettings> = use_signal(SecuritySettings::default);
24        let mut draft: Signal<SecuritySettings> = use_signal(SecuritySettings::default);
25        let loading = use_signal(|| true);
26        let mut saving = use_signal(|| false);
27        let mut just_saved = use_signal(|| false);
28
29        use_effect(move || {
30            #[cfg(target_arch = "wasm32")]
31            {
32                let mut saved = saved;
33                let mut draft = draft;
34                let mut loading = loading;
35                spawn(async move {
36                    match get_security_settings().await {
37                        Ok(s) => {
38                            saved.set(s.clone());
39                            draft.set(s);
40                        }
41                        Err(e) => toast.call((format!("加载失败:{e}"), true)),
42                    }
43                    loading.set(false);
44                });
45            }
46        });
47
48        let dirty = use_memo(move || draft() != saved());
49
50        rsx! {
51            div { class: "space-y-6",
52                div { class: "{ADMIN_CARD_CLASS} p-6 md:p-8 flex flex-col gap-6",
53                    div { class: "flex items-center gap-3",
54                        span { class: "inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] border border-[var(--color-paper-border)]",
55                            svg {
56                                class: "w-5 h-5",
57                                view_box: "0 0 24 24",
58                                fill: "none",
59                                stroke: "currentColor",
60                                stroke_width: "1.8",
61                                stroke_linecap: "round",
62                                stroke_linejoin: "round",
63                                path { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }
64                            }
65                        }
66                        div {
67                            h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]",
68                                "安全配置"
69                            }
70                            p { class: "text-sm text-[var(--color-paper-secondary)] mt-0.5",
71                                "CSRF 可信来源、Cookie 安全、真实 IP 提取与会话管理。保存后即时生效。"
72                            }
73                        }
74                    }
75
76                    // CSRF 可信源
77                    div { class: "flex flex-col gap-2 max-w-xl",
78                        FormLabel {
79                            label: "CSRF 可信来源 (APP_BASE_URL)",
80                            html_for: Some("sec-base-url".to_string()),
81                        }
82                        FormInput {
83                            id: Some("sec-base-url".to_string()),
84                            r#type: "url",
85                            placeholder: "https://your-domain.example",
86                            value: draft().app_base_url,
87                            oninput: move |v: String| {
88                                let mut d = draft();
89                                d.app_base_url = v;
90                                draft.set(d);
91                                just_saved.set(false);
92                            },
93                        }
94                        p { class: "text-xs text-[var(--color-paper-secondary)]",
95                            "写请求(POST/PUT/PATCH/DELETE)的 CSRF 校验可信来源。留空回退到 Host 头推导,生产环境强烈建议显式设置。"
96                        }
97                    }
98
99                    // Cookie Secure
100                    label { class: "flex items-center gap-3 cursor-pointer max-w-xl",
101                        Checkbox {
102                            checked: draft().cookie_secure,
103                            onchange: move |checked: bool| {
104                                let mut d = draft();
105                                d.cookie_secure = checked;
106                                draft.set(d);
107                                just_saved.set(false);
108                            },
109                        }
110                        div { class: "flex flex-col",
111                            span { class: "text-sm font-medium text-[var(--color-paper-primary)]",
112                                "Cookie Secure 标志"
113                            }
114                            span { class: "text-xs text-[var(--color-paper-secondary)]",
115                                "启用后浏览器仅在 HTTPS 下发送会话 Cookie。HTTP 生产环境必开。"
116                            }
117                        }
118                    }
119
120                    // 代理层数
121                    div { class: "flex flex-col gap-2 max-w-xl",
122                        FormLabel {
123                            label: "反向代理层数 (TRUSTED_PROXY_COUNT)",
124                            html_for: Some("sec-proxy".to_string()),
125                        }
126                        FormSelect {
127                            id: Some("sec-proxy".to_string()),
128                            value: draft().trusted_proxy_count as i32,
129                            options: vec![
130                                (0, "0(直接对外服务)"),
131                                (1, "1(一层代理,如 nginx/Caddy)"),
132                                (2, "2(两层代理)"),
133                                (3, "3"),
134                                (4, "4"),
135                                (5, "5"),
136                            ],
137                            onchange: move |v: i32| {
138                                let mut d = draft();
139                                d.trusted_proxy_count = v.max(0) as u32;
140                                draft.set(d);
141                                just_saved.set(false);
142                            },
143                        }
144                        p { class: "text-xs text-[var(--color-paper-secondary)]",
145                            "用于从 X-Forwarded-For 提取真实客户端 IP。设错会允许 IP 伪造(绕过限流)或限流对错对象。"
146                        }
147                    }
148
149                    // 并发会话上限
150                    div { class: "flex flex-col gap-2 max-w-xl",
151                        FormLabel {
152                            label: "单用户最大并发会话数",
153                            html_for: Some("sec-sessions".to_string()),
154                        }
155                        FormSelect {
156                            id: Some("sec-sessions".to_string()),
157                            value: draft().max_sessions_per_user as i32,
158                            options: vec![
159                                (1, "1(仅单设备登录)"),
160                                (3, "3"),
161                                (5, "5(默认)"),
162                                (10, "10"),
163                                (20, "20"),
164                                (50, "50"),
165                            ],
166                            onchange: move |v: i32| {
167                                let mut d = draft();
168                                d.max_sessions_per_user = v.max(1) as u32;
169                                draft.set(d);
170                                just_saved.set(false);
171                            },
172                        }
173                        p { class: "text-xs text-[var(--color-paper-secondary)]",
174                            "超出上限时按最旧优先淘汰——新设备登录会让最老的会话自动失效。"
175                        }
176                    }
177
178                    // 操作行
179                    div { class: "flex items-center justify-between gap-4 pt-1",
180                        if just_saved() {
181                            span { class: "inline-flex items-center gap-1.5 text-xs text-[var(--color-paper-accent)]",
182                                svg {
183                                    class: "w-3.5 h-3.5",
184                                    view_box: "0 0 24 24",
185                                    fill: "none",
186                                    stroke: "currentColor",
187                                    stroke_width: "2.5",
188                                    path {
189                                        stroke_linecap: "round",
190                                        stroke_linejoin: "round",
191                                        d: "M5 13l4 4L19 7",
192                                    }
193                                }
194                                "已保存"
195                            }
196                        } else if dirty() {
197                            span { class: "text-xs text-[var(--color-paper-secondary)]",
198                                "有未保存的更改"
199                            }
200                        } else {
201                            span { class: "text-xs text-transparent select-none", "·" }
202                        }
203                        LoadingButton {
204                            label: "保存设置".to_string(),
205                            loading: saving(),
206                            disabled: loading() || just_saved() || !dirty(),
207                            onclick: move |_| {
208                                let d = draft();
209                                saving.set(true);
210                                spawn(async move {
211                                    match update_security_settings(
212                                            d.app_base_url,
213                                            d.cookie_secure,
214                                            d.trusted_proxy_count,
215                                            d.max_sessions_per_user,
216                                        )
217                                        .await
218                                    {
219                                        Ok(s) => {
220                                            saved.set(s.clone());
221                                            draft.set(s);
222                                            just_saved.set(true);
223                                            toast.call(("保存成功".to_string(), false));
224                                        }
225                                        Err(e) => toast.call((format!("保存失败:{e}"), true)),
226                                    }
227                                    saving.set(false);
228                                });
229                            },
230                        }
231                    }
232                }
233            }
234        }
235    }
236    #[cfg(not(target_arch = "wasm32"))]
237    {
238        rsx! {
239            div { class: "p-8 text-[var(--color-paper-secondary)]", "安全配置(前端渲染)" }
240        }
241    }
242}