yggdrasil/pages/admin/
profile.rs1use std::sync::Arc;
10
11use leptos::prelude::*;
12use leptos::task::spawn_local;
13use leptos_router::{lazy_route, LazyRoute};
14
15use crate::components::admin_layout::{AdminActions, AdminPageHead};
16use crate::components::assets::{AssetPickerModal, AssetSelection};
17use crate::components::forms::FormInput;
18use crate::components::skeletons::profile_skeleton::ProfileSkeleton;
19use crate::components::ui::{
20 button_class, use_toast, ButtonSize, ButtonVariant, Checkbox, Icon, IconName, LoadError,
21};
22use crate::context::UserContext;
23use crate::models::user::PublicUser;
24use crate::utils::error::failure_message;
25
26const MIN_PASSWORD: usize = 8;
28
29fn password_level(pw: &str) -> u8 {
31 let len = pw.chars().count();
32 if len == 0 {
33 0
34 } else if len < MIN_PASSWORD {
35 1
36 } else if len < 12 {
37 2
38 } else if pw.chars().any(|c| !c.is_ascii_alphanumeric()) {
39 4
40 } else {
41 3
42 }
43}
44
45fn password_hint(pw: &str) -> String {
47 let len = pw.chars().count();
48 match password_level(pw) {
49 0 => format!("至少 {MIN_PASSWORD} 位。"),
50 1 => format!("还差 {} 位。", MIN_PASSWORD - len),
51 4 => "强度不错。".into(),
52 _ => "可以再长一些,或加入符号。".into(),
53 }
54}
55
56pub struct ProfileRoute;
58
59#[lazy_route]
60impl LazyRoute for ProfileRoute {
61 fn data() -> Self {
62 Self
63 }
64
65 fn view(_this: Self) -> AnyView {
66 view! { <Profile/> }.into_any()
67 }
68}
69
70#[component]
72fn Profile() -> impl IntoView {
73 let user = RwSignal::new(None::<PublicUser>);
74 let env_admin = RwSignal::new(false);
75 let error = RwSignal::new(false);
76 let reload = RwSignal::new(0_u32);
77 let generation = StoredValue::new(0_u64);
78 let actions = use_context::<AdminActions>();
79
80 Effect::new(move |_| {
82 reload.track();
83 let mut id = 0;
84 generation.update_value(|g| {
85 *g += 1;
86 id = *g;
87 });
88 spawn_local(async move {
89 let result = crate::api::profile::get_profile().await;
90 if generation.try_get_value() != Some(id) {
91 return;
92 }
93 match result {
94 Ok(resp) => {
95 let _ = env_admin.try_set(resp.admin_env_active);
96 let _ = user.try_set(Some(resp.user));
97 let _ = error.try_set(false);
98 }
99 Err(_) => {
100 let _ = error.try_set(true);
101 }
102 }
103 });
104 });
105
106 let user_id = Memo::new(move |_| user.with(|u| u.as_ref().map(|u| u.id)));
108
109 view! {
110 <div class="admin-profile">
111 <AdminPageHead title="个人资料">"当前登录账号的展示资料与登录密码。"</AdminPageHead>
112 {move || {
113 env_admin
114 .get()
115 .then(|| {
116 view! {
117 <p class="admin-profile-note" role="note">
118 <Icon name=IconName::TriangleAlert size=16/>
119 "已启用 ADMIN_* 环境变量:每次服务重启后,邮箱与密码会以环境变量为准。"
120 </p>
121 }
122 })
123 }}
124 {move || {
125 if error.get() {
126 view! {
127 <LoadError
128 title="个人资料加载失败"
129 on_retry=move |()| reload.update(|n| *n += 1)
130 />
131 }
132 .into_any()
133 } else if user_id.get().is_some() {
134 view! {
135 <InfoSection user=user on_saved=move |u: PublicUser| user.set(Some(u))/>
136 <PasswordSection/>
137 <section class="admin-profile-logout" aria-labelledby="profile-out-h">
138 <div>
139 <h2 id="profile-out-h">"退出登录"</h2>
140 <p>"只退出当前设备。"</p>
141 </div>
142 <button
143 class=button_class(ButtonVariant::Outline, ButtonSize::Md)
144 data-danger="true"
145 type="button"
146 on:click=move |_| {
147 if let Some(actions) = actions {
148 actions.logout.run(());
149 }
150 }
151 >
152 <Icon name=IconName::LogOut size=15/>
153 "退出登录"
154 </button>
155 </section>
156 }
157 .into_any()
158 } else {
159 view! { <ProfileSkeleton/> }.into_any()
160 }
161 }}
162 </div>
163 }
164}
165
166#[component]
168fn InfoSection(
169 user: RwSignal<Option<PublicUser>>,
170 #[prop(into)] on_saved: Callback<PublicUser>,
171) -> impl IntoView {
172 let toast = use_toast();
173 let ctx = expect_context::<UserContext>();
174 let base_display = Memo::new(move |_| {
175 user.with(|u| {
176 u.as_ref()
177 .and_then(|u| u.display_name.clone())
178 .unwrap_or_default()
179 })
180 });
181 let base_email =
182 Memo::new(move |_| user.with(|u| u.as_ref().map(|u| u.email.clone()).unwrap_or_default()));
183 let base_avatar =
184 Memo::new(move |_| user.with(|u| u.as_ref().and_then(|u| u.avatar_url.clone())));
185 let username = Memo::new(move |_| {
186 user.with(|u| u.as_ref().map(|u| u.username.clone()).unwrap_or_default())
187 });
188 let display = RwSignal::new(base_display.get_untracked());
189 let email = RwSignal::new(base_email.get_untracked());
190 let avatar = RwSignal::new(base_avatar.get_untracked());
191 let saving = RwSignal::new(false);
192 let picker = RwSignal::new(false);
193 let uploading = RwSignal::new(false);
194 let avatar_failed = RwSignal::new(false);
195
196 let dirty = Memo::new(move |_| {
197 display.with(|d| d.trim() != base_display.get())
198 || email.with(|e| e.trim() != base_email.get())
199 || avatar.get() != base_avatar.get()
200 });
201 let initial = Memo::new(move |_| {
202 let label = display.with(|d| {
203 if d.trim().is_empty() {
204 username.get()
205 } else {
206 d.trim().to_string()
207 }
208 });
209 label
210 .chars()
211 .next()
212 .map(|c| c.to_uppercase().to_string())
213 .unwrap_or_default()
214 });
215
216 let save = move |e: leptos::ev::SubmitEvent| {
217 e.prevent_default();
218 if saving.get_untracked() {
219 return;
220 }
221 saving.set(true);
222 let (mail, name, av) = (
223 email.get_untracked().trim().to_string(),
224 display.get_untracked().trim().to_string(),
225 avatar.get_untracked(),
226 );
227 spawn_local(async move {
228 match crate::api::profile::update_profile(
229 mail,
230 Some(name).filter(|n| !n.is_empty()),
231 av,
232 )
233 .await
234 {
235 Ok(resp) if resp.success => {
236 if let Some(u) = resp.user {
237 let _ = ctx.user.try_set(Some(Arc::new(u.clone())));
238 on_saved.try_run(u);
239 }
240 toast.success("资料已保存");
241 }
242 Ok(resp) => toast.error(resp.message),
243 Err(e) => toast.error(failure_message("保存失败", &e)),
244 }
245 let _ = saving.try_set(false);
246 });
247 };
248 let upload = move |evt: leptos::ev::Event| {
249 #[cfg(target_arch = "wasm32")]
250 {
251 let Some(file) = event_target::<web_sys::HtmlInputElement>(&evt)
252 .files()
253 .and_then(|list| list.item(0))
254 else {
255 return;
256 };
257 if let Err(msg) = crate::components::assets::upload_pool::validate_file(
258 &file.type_(),
259 file.size() as u64,
260 ) {
261 toast.error(msg);
262 return;
263 }
264 uploading.set(true);
265 spawn_local(async move {
266 match crate::bridges::tiptap::upload_image_file(file).await {
267 Ok(url) => {
268 let _ = avatar.try_set(Some(url));
269 let _ = avatar_failed.try_set(false);
270 }
271 Err(msg) => toast.error(format!("上传失败:{msg}")),
272 }
273 let _ = uploading.try_set(false);
274 });
275 }
276 #[cfg(not(target_arch = "wasm32"))]
277 let _ = (evt, toast);
278 };
279
280 let on_pick = move |picks: Vec<AssetSelection>| {
282 if let Some(first) = picks.into_iter().next() {
283 avatar.set(Some(first.url));
284 avatar_failed.set(false);
285 }
286 };
287
288 view! {
289 <form
290 class="admin-panel admin-profile-section"
291 aria-labelledby="profile-info-h"
292 on:submit=save
293 >
294 <div class="admin-profile-section-head">
295 <h2 id="profile-info-h">"个人信息"</h2>
296 <p>"显示名称会出现在后台侧栏和文章署名;邮箱用于登录。"</p>
297 </div>
298 <div class="admin-profile-rows">
299 <div class="admin-profile-row">
300 <span class="admin-profile-label">"头像"</span>
301 <div class="admin-profile-avatar-row">
302 <span class="admin-profile-avatar" aria-hidden="true">
303 {move || match avatar.get().filter(|_| !avatar_failed.get()) {
304 Some(url) => {
305 view! {
306 <img src=url alt="" on:error=move |_| avatar_failed.set(true)/>
307 }
308 .into_any()
309 }
310 None => initial.get().into_any(),
311 }}
312 </span>
313 <button
314 class=button_class(ButtonVariant::Secondary, ButtonSize::Sm)
315 type="button"
316 on:click=move |_| picker.set(true)
317 >
318 "从素材库选择"
319 </button>
320 <label
321 class=format!(
322 "{} admin-profile-upload",
323 button_class(ButtonVariant::Secondary, ButtonSize::Sm),
324 )
325 aria-disabled=move || uploading.get().then_some("true")
326 >
327 {move || if uploading.get() { "上传中…" } else { "上传" }}
328 <input
329 type="file"
330 class="sr-only"
331 accept="image/jpeg,image/png,image/gif,image/webp"
332 disabled=move || uploading.get()
333 on:change=upload
334 />
335 </label>
336 {move || {
337 avatar
338 .with(Option::is_some)
339 .then(|| {
340 view! {
341 <button
342 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
343 type="button"
344 on:click=move |_| avatar.set(None)
345 >
346 "移除"
347 </button>
348 }
349 })
350 }}
351 </div>
352 </div>
353 <div class="admin-profile-row">
354 <span class="admin-profile-label">"用户名"</span>
355 <div>
356 <div class="admin-profile-readonly">{move || username.get()}</div>
357 <p class="admin-profile-hint">"用户名是唯一的登录凭据,不能修改。"</p>
358 </div>
359 </div>
360 <div class="admin-profile-row">
361 <label class="admin-profile-label" for="profile-display">
362 "显示名称"
363 </label>
364 <FormInput
365 id="profile-display"
366 placeholder="留空则显示用户名"
367 maxlength=50
368 value=display
369 oninput=move |v: String| display.set(v)
370 />
371 </div>
372 <div class="admin-profile-row">
373 <label class="admin-profile-label" for="profile-email">
374 "邮箱"
375 </label>
376 <FormInput
377 id="profile-email"
378 class="field admin-profile-mono"
379 r#type="email"
380 placeholder="name@example.com"
381 value=email
382 oninput=move |v: String| email.set(v)
383 />
384 </div>
385 </div>
386 <div class="admin-profile-foot">
387 <button
388 class=button_class(ButtonVariant::Primary, ButtonSize::Md)
389 type="submit"
390 disabled=move || !dirty.get() || saving.get()
391 >
392 {move || if saving.get() { "保存中…" } else { "保存" }}
393 </button>
394 </div>
395 </form>
396 <AssetPickerModal
397 visible=picker
398 cover_uploading=uploading
399 title="选择头像"
400 on_select=on_pick
401 />
402 }
403}
404
405#[component]
407fn PasswordSection() -> impl IntoView {
408 let toast = use_toast();
409 let current = RwSignal::new(String::new());
410 let next = RwSignal::new(String::new());
411 let confirm = RwSignal::new(String::new());
412 let busy = RwSignal::new(false);
413 let revoke_mcp = RwSignal::new(false);
414
415 let level = Memo::new(move |_| next.with(|n| password_level(n)));
416 let mismatch = Memo::new(move |_| confirm.with(|c| next.with(|n| !c.is_empty() && c != n)));
417 let can_submit = Memo::new(move |_| {
418 !busy.get()
419 && current.with(|c| !c.is_empty())
420 && next.with(|n| n.chars().count() >= MIN_PASSWORD)
421 && confirm.with(|c| next.with(|n| c == n))
422 });
423
424 let submit = move |e: leptos::ev::SubmitEvent| {
425 e.prevent_default();
426 if !can_submit.get_untracked() {
427 return;
428 }
429 busy.set(true);
430 let (old, new) = (current.get_untracked(), next.get_untracked());
431 let revoke = revoke_mcp.get_untracked();
432 spawn_local(async move {
433 match crate::api::profile::change_password(old, new, revoke).await {
434 Ok(resp) if resp.success => {
435 let _ = current.try_set(String::new());
436 let _ = next.try_set(String::new());
437 let _ = confirm.try_set(String::new());
438 let _ = revoke_mcp.try_set(false);
439 toast.success(resp.message);
440 }
441 Ok(resp) => toast.error(resp.message),
442 Err(e) => toast.error(failure_message("修改失败", &e)),
443 }
444 let _ = busy.try_set(false);
445 });
446 };
447
448 view! {
449 <form
450 class="admin-panel admin-profile-section"
451 aria-labelledby="profile-pw-h"
452 on:submit=submit
453 >
454 <div class="admin-profile-section-head">
455 <h2 id="profile-pw-h">"修改密码"</h2>
456 <p>"修改成功后,其他设备上的登录会自动退出,当前设备保持登录。"</p>
457 </div>
458 <div class="admin-profile-rows">
459 <div class="admin-profile-row">
460 <label class="admin-profile-label" for="profile-pw0">
461 "当前密码"
462 </label>
463 <FormInput
464 id="profile-pw0"
465 r#type="password"
466 autocomplete="current-password"
467 value=current
468 oninput=move |v: String| current.set(v)
469 />
470 </div>
471 <div class="admin-profile-row">
472 <label class="admin-profile-label" for="profile-pw1">
473 "新密码"
474 </label>
475 <div>
476 <FormInput
477 id="profile-pw1"
478 r#type="password"
479 autocomplete="new-password"
480 aria_describedby="profile-pw1-hint"
481 value=next
482 oninput=move |v: String| next.set(v)
483 />
484 <div
485 class="admin-profile-meter"
486 data-level=move || level.get().to_string()
487 aria-hidden="true"
488 >
489 {(1..=4u8)
490 .map(|i| {
491 view! { <span data-on=move || (i <= level.get()).then_some("true")></span> }
492 })
493 .collect_view()}
494 </div>
495 <p id="profile-pw1-hint" class="admin-profile-hint">
496 {move || next.with(|n| password_hint(n))}
497 </p>
498 </div>
499 </div>
500 <div class="admin-profile-row">
501 <label class="admin-profile-label" for="profile-pw2">
502 "确认新密码"
503 </label>
504 <div>
505 <FormInput
506 id="profile-pw2"
507 r#type="password"
508 autocomplete="new-password"
509 invalid=mismatch
510 value=confirm
511 oninput=move |v: String| confirm.set(v)
512 />
513 {move || {
514 mismatch
515 .get()
516 .then(|| {
517 view! {
518 <p class="admin-profile-hint" data-error="true" role="alert">
519 "两次输入的新密码不一致。"
520 </p>
521 }
522 })
523 }}
524 </div>
525 </div>
526 <div class="admin-profile-row">
527 <span class="admin-profile-label">"MCP 令牌"</span>
528 <div>
529 <label class="admin-profile-check">
530 <Checkbox
531 checked=revoke_mcp
532 onchange=move |v: bool| revoke_mcp.set(v)
533 />
534 "同时吊销我的全部 MCP 令牌"
535 </label>
536 <p class="admin-profile-hint">
537 "令牌默认不随改密失效。怀疑密码泄露时勾选,AI 客户端需重新配置令牌。"
538 </p>
539 </div>
540 </div>
541 </div>
542 <div class="admin-profile-foot">
543 <button
544 class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
545 type="submit"
546 disabled=move || !can_submit.get()
547 >
548 {move || if busy.get() { "修改中…" } else { "修改密码" }}
549 </button>
550 </div>
551 </form>
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
560 fn password_level_grows_with_length_and_symbols() {
561 assert_eq!(password_level(""), 0);
562 assert_eq!(password_level("abc"), 1);
563 assert_eq!(password_level("abcdefgh"), 2);
564 assert_eq!(password_level("abcdefghijkl"), 3);
565 assert_eq!(password_level("abcdefghijk!"), 4);
566 assert_eq!(password_level("一二三四五六七八"), 2);
568 }
569
570 #[test]
571 fn password_hint_counts_missing_characters() {
572 assert_eq!(password_hint(""), "至少 8 位。");
573 assert_eq!(password_hint("abcde"), "还差 3 位。");
574 assert_eq!(password_hint("abcdefghijk!"), "强度不错。");
575 }
576}