yggdrasil/pages/admin/mcp/
token_editor.rs1use super::{scope_desc, OpenToken};
3use crate::api::mcp_tokens::{create_mcp_token, update_mcp_token_permissions, TokenLifetime};
4use crate::components::forms::FormInput;
5use crate::components::ui::{
6 button_class, use_toast, ButtonSize, ButtonVariant, Checkbox, IconButton, IconName, ModalShell,
7 SegmentedControl, SegmentedItem, SegmentedKind, SegmentedSize,
8};
9use crate::models::mcp_token::{validate_token_name, McpTokenSummary, NoteGrant, TokenScope};
10use crate::models::note::Notebook;
11use crate::utils::error::failure_message;
12use leptos::ev::SubmitEvent;
13use leptos::prelude::*;
14use leptos::task::spawn_local;
15
16const SCOPES: [TokenScope; 3] = [TokenScope::Read, TokenScope::Write, TokenScope::Admin];
17const LIFETIMES: [(TokenLifetime, &str); 5] = [
18 (TokenLifetime::Days1, "1 天"),
19 (TokenLifetime::Days7, "7 天"),
20 (TokenLifetime::Days30, "30 天"),
21 (TokenLifetime::Days90, "90 天"),
22 (TokenLifetime::Never, "永不过期"),
23];
24
25pub(super) fn use_close_after_exit(visible: RwSignal<bool>, on_close: Callback<()>) {
27 Effect::new(move |_| {
28 if !visible.get() {
29 spawn_local(async move {
30 crate::utils::time::sleep_ms(crate::components::ui::EXIT_ANIM_MS).await;
31 let _ = on_close.try_run(());
32 });
33 }
34 });
35}
36
37#[derive(Clone, Copy)]
39struct GrantDraft {
40 read: RwSignal<bool>,
41 write: RwSignal<bool>,
42 restricted: RwSignal<bool>,
43 books: RwSignal<Vec<i32>>,
44}
45
46impl GrantDraft {
47 fn new(grant: &NoteGrant) -> Self {
48 Self {
49 read: RwSignal::new(grant.read),
50 write: RwSignal::new(grant.write),
51 restricted: RwSignal::new(grant.notebook_ids.is_some()),
52 books: RwSignal::new(grant.notebook_ids.clone().unwrap_or_default()),
53 }
54 }
55
56 fn missing_books(&self) -> bool {
58 self.read.get() && self.restricted.get() && self.books.with(Vec::is_empty)
59 }
60
61 fn grant(&self) -> NoteGrant {
62 let read = self.read.get_untracked();
63 NoteGrant {
64 read,
65 write: read && self.write.get_untracked(),
66 notebook_ids: (read && self.restricted.get_untracked())
67 .then(|| self.books.get_untracked()),
68 }
69 }
70}
71
72#[component]
74fn ScopeCards(scope: RwSignal<TokenScope>) -> impl IntoView {
75 view! {
76 <fieldset class="mcp-fieldset">
77 <legend>"文章权限"</legend>
78 <div class="mcp-scopes" role="radiogroup" aria-label="文章权限">
79 {SCOPES
80 .into_iter()
81 .map(|s| {
82 view! {
83 <button
84 class="mcp-scope"
85 type="button"
86 role="radio"
87 aria-checked=move || (scope.get() == s).to_string()
88 on:click=move |_| scope.set(s)
89 >
90 <span class="mcp-scope-key">{s.as_str()}</span>
91 <span class="mcp-scope-desc">{scope_desc(s)}</span>
92 </button>
93 }
94 })
95 .collect_view()}
96 </div>
97 </fieldset>
98 }
99}
100
101#[component]
103fn GrantFields(draft: GrantDraft, #[prop(into)] submitted: Signal<bool>) -> impl IntoView {
104 let GrantDraft {
105 read,
106 write,
107 restricted,
108 books,
109 } = draft;
110 let notebooks = RwSignal::new(None::<Result<Vec<Notebook>, ()>>);
113 Effect::new(move |_| {
114 spawn_local(async move {
115 let result = crate::api::notes::owned_notebooks().await.map_err(|_| ());
116 let _ = notebooks.try_set(Some(result));
117 });
118 });
119
120 view! {
121 <fieldset class="mcp-fieldset">
122 <legend>"笔记授权"</legend>
123 <p class="mcp-muted">"与文章权限独立。笔记写入授权也允许公开、撤回及管理 AI 知识库收录;创建和编辑不会自动公开或收录。"</p>
124 <label class="mcp-check">
125 <Checkbox
126 checked=read
127 onchange=move |v: bool| {
128 read.set(v);
129 if !v {
130 write.set(false);
131 }
132 }
133 />
134 "读取我的知识库"
135 </label>
136 <label class="mcp-check">
137 <Checkbox
138 checked=write
139 onchange=move |v: bool| {
140 write.set(v);
141 if v {
142 read.set(true);
143 }
144 }
145 />
146 "允许编辑、公开和收录笔记"
147 </label>
148 {move || {
149 read.get()
150 .then(|| {
151 view! {
152 <label class="mcp-check">
153 <Checkbox
154 checked=restricted
155 onchange=move |v: bool| restricted.set(v)
156 />
157 "只授权指定的笔记本"
158 </label>
159 {move || {
160 restricted
161 .get()
162 .then(|| {
163 view! {
164 <div class="mcp-books" role="group" aria-label="授权的笔记本">
165 {move || match notebooks.get() {
166 Some(Ok(items)) if items.is_empty() => {
167 view! { <span class="mcp-muted">"还没有笔记本。"</span> }
168 .into_any()
169 }
170 Some(Ok(items)) => {
171 items
172 .into_iter()
173 .map(|book| {
174 let id = book.id;
175 view! {
176 <button
177 class="note-book"
178 type="button"
179 aria-pressed=move || {
180 books.with(|ids| ids.contains(&id)).to_string()
181 }
182 on:click=move |_| {
183 books
184 .update(|ids| {
185 if let Some(i) = ids.iter().position(|x| *x == id) {
186 ids.remove(i);
187 } else {
188 ids.push(id);
189 }
190 })
191 }
192 >
193 {book.title}
194 </button>
195 }
196 })
197 .collect_view()
198 .into_any()
199 }
200 Some(Err(())) => {
201 view! { <span class="mcp-error">"笔记本加载失败。"</span> }
202 .into_any()
203 }
204 None => {
205 view! { <span class="mcp-muted">"正在读取笔记本…"</span> }
206 .into_any()
207 }
208 }}
209 </div>
210 {move || {
211 (submitted.get() && draft.missing_books())
212 .then(|| {
213 view! {
214 <p class="mcp-error" role="alert">
215 "请至少选择一个笔记本。"
216 </p>
217 }
218 })
219 }}
220 }
221 })
222 }}
223 }
224 })
225 }}
226 </fieldset>
227 }
228}
229
230#[component]
232pub(super) fn CreateTokenDialog(
233 #[prop(into)] on_close: Callback<()>,
234 #[prop(into)] on_created: Callback<OpenToken>,
235) -> impl IntoView {
236 let toast = use_toast();
237 let visible = RwSignal::new(true);
238 let closing = RwSignal::new(false);
239 let name = RwSignal::new(String::new());
240 let scope = RwSignal::new(TokenScope::Read);
241 let lifetime = RwSignal::new(TokenLifetime::Days30);
242 let draft = GrantDraft::new(&NoteGrant::default());
243 let submitted = RwSignal::new(false);
244 let busy = RwSignal::new(false);
245 use_close_after_exit(visible, on_close);
246
247 let lifetime_items: Vec<SegmentedItem<TokenLifetime>> = LIFETIMES
248 .iter()
249 .map(|(v, l)| SegmentedItem::new(*v, *l))
250 .collect();
251 let name_error = move || name.with(|n| validate_token_name(n).err());
252 let length = move || name.with(|n| n.trim().chars().count());
253 let close = move || {
254 closing.set(true);
255 visible.set(false);
256 };
257 let submit = move |e: SubmitEvent| {
258 e.prevent_default();
259 submitted.set(true);
260 if busy.get_untracked() || name_error().is_some() || draft.missing_books() {
261 return;
262 }
263 busy.set(true);
264 let (n, sc, lt, grant) = (
265 name.get_untracked().trim().to_string(),
266 scope.get_untracked(),
267 lifetime.get_untracked(),
268 draft.grant(),
269 );
270 spawn_local(async move {
271 match create_mcp_token(n, sc, lt, Some(grant)).await {
272 Ok(resp) => {
273 let _ = on_created.try_run(OpenToken {
274 summary: resp.summary,
275 plaintext: resp.plaintext,
276 created: true,
277 });
278 }
279 Err(e) => toast.error(failure_message("创建失败", &e)),
280 }
281 let _ = busy.try_set(false);
282 });
283 };
284
285 view! {
286 <ModalShell
287 visible=visible
288 closing=closing
289 title="新建令牌"
290 overlay_padding="p-4"
291 panel_class="mcp-dialog"
292 >
293 <form class="mcp-form" on:submit=submit>
294 <div class="mcp-dialog-head">
295 <div>
296 <h2>"新建令牌"</h2>
297 <p>"创建后会显示令牌明文,之后也可以在列表里重新查看。"</p>
298 </div>
299 <IconButton
300 icon=IconName::X
301 label="关闭"
302 size=ButtonSize::Sm
303 onclick=move |_| close()
304 />
305 </div>
306 <div class="mcp-dialog-body">
307 <div>
308 <label class="mcp-label" for="mcp-token-name">
309 <span>"名称"</span>
310 <span class="mcp-count">{move || format!("{} / 64", length())}</span>
311 </label>
312 <FormInput
313 id="mcp-token-name".to_string()
314 placeholder="如 claude-code-macbook"
315 autofocus=true
316 invalid=move || submitted.get() && name_error().is_some()
317 value=name
318 oninput=move |v: String| name.set(v)
319 />
320 {move || {
321 submitted
322 .get()
323 .then(name_error)
324 .flatten()
325 .map(|err| view! { <p class="mcp-error" role="alert">{err}</p> })
326 }}
327 </div>
328 <ScopeCards scope=scope/>
329 <GrantFields draft=draft submitted=submitted/>
330 <div class="mcp-lifetime">
331 <span class="mcp-label">"有效期"</span>
332 <SegmentedControl
333 items=lifetime_items.clone()
334 value=lifetime
335 on_change=move |v: TokenLifetime| lifetime.set(v)
336 aria_label="有效期"
337 kind=SegmentedKind::Radio
338 size=SegmentedSize::Sm
339 />
340 </div>
341 </div>
342 <div class="mcp-dialog-foot">
343 <button
344 class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
345 type="button"
346 on:click=move |_| close()
347 >
348 "取消"
349 </button>
350 <button
351 class=button_class(ButtonVariant::Primary, ButtonSize::Md)
352 type="submit"
353 disabled=move || busy.get() || name.with(|n| n.trim().is_empty())
354 >
355 {move || if busy.get() { "创建中…" } else { "创建令牌" }}
356 </button>
357 </div>
358 </form>
359 </ModalShell>
360 }
361}
362
363#[component]
365pub(super) fn EditPermissionsDialog(
366 token: McpTokenSummary,
367 #[prop(into)] on_close: Callback<()>,
368 #[prop(into)] on_saved: Callback<()>,
369) -> impl IntoView {
370 let toast = use_toast();
371 let visible = RwSignal::new(true);
372 let closing = RwSignal::new(false);
373 let scope = RwSignal::new(token.scope);
374 let draft = GrantDraft::new(&token.notes);
375 let submitted = RwSignal::new(false);
376 let busy = RwSignal::new(false);
377 use_close_after_exit(visible, on_close);
378 let id = StoredValue::new(token.id.clone());
379 let subtitle = format!("「{}」继续使用原来的密钥与有效期。", token.name);
380 let close = move || {
381 closing.set(true);
382 visible.set(false);
383 };
384 let save = move |e: SubmitEvent| {
385 e.prevent_default();
386 submitted.set(true);
387 if busy.get_untracked() || draft.missing_books() {
388 return;
389 }
390 busy.set(true);
391 let (id, sc, grant) = (id.get_value(), scope.get_untracked(), draft.grant());
392 spawn_local(async move {
393 match update_mcp_token_permissions(id, sc, grant).await {
394 Ok(()) => {
395 toast.success("权限已更新,原令牌继续有效");
396 let _ = on_saved.try_run(());
397 }
398 Err(e) => toast.error(failure_message("保存失败", &e)),
399 }
400 let _ = busy.try_set(false);
401 });
402 };
403 view! {
404 <ModalShell
405 visible=visible
406 closing=closing
407 title="编辑令牌权限"
408 overlay_padding="p-4"
409 panel_class="mcp-dialog"
410 >
411 <form class="mcp-form" on:submit=save>
412 <div class="mcp-dialog-head">
413 <div>
414 <h2>"编辑令牌权限"</h2>
415 <p>{subtitle.clone()}</p>
416 </div>
417 <IconButton
418 icon=IconName::X
419 label="关闭"
420 size=ButtonSize::Sm
421 onclick=move |_| close()
422 />
423 </div>
424 <div class="mcp-dialog-body">
425 <ScopeCards scope=scope/>
426 <GrantFields draft=draft submitted=submitted/>
427 </div>
428 <div class="mcp-dialog-foot">
429 <button
430 class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
431 type="button"
432 on:click=move |_| close()
433 >
434 "取消"
435 </button>
436 <button
437 class=button_class(ButtonVariant::Primary, ButtonSize::Md)
438 type="submit"
439 disabled=move || busy.get()
440 >
441 {move || if busy.get() { "保存中…" } else { "保存权限" }}
442 </button>
443 </div>
444 </form>
445 </ModalShell>
446 }
447}