yggdrasil/pages/admin/settings/
security_section.rs1use 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#[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 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 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 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 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 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}