Skip to main content

yggdrasil/pages/admin/mcp/
token_editor.rs

1//! 新建令牌与编辑权限对话框,以及两者共用的笔记授权字段。
2use 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
25/// 关闭时先播退出动画,再通知父组件卸载。
26pub(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/// 笔记授权草稿。
38#[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    /// 限定笔记本却一个也没选(读取响应式状态,可在闭包里追踪)。
57    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/// 文章权限:三张单选卡片。
73#[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/// 笔记授权:知识库读取与笔记管理,可限定笔记本。
102#[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    // 对话框只在浏览器里打开:笔记本只在客户端请求(私人数据不进 SSR 缓存)。
111    // 不用 Resource:它会被外层 Suspense 计入挂起。`None` = 读取中。
112    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/// 新建令牌对话框。创建成功后把明文交给令牌对话框。
231#[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/// 编辑令牌权限:不轮换密钥、不改有效期。
364#[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}