Skip to main content

yggdrasil/pages/admin/mcp/
tokens.rs

1//! 已签发的令牌表格:名称、文章权限、笔记授权、有效期、最近使用与操作。
2use super::{is_active, notes_desc, OpenToken};
3use crate::api::mcp_tokens::{reveal_mcp_token, revoke_mcp_token};
4use crate::components::empty_state::EmptyState;
5use crate::components::skeletons::atoms::SkeletonBox;
6use crate::components::ui::{
7    use_toast, ConfirmDialog, IconName, LoadError, Menu, MenuItem, StatusDot,
8};
9use crate::models::mcp_token::McpTokenSummary;
10use crate::utils::error::failure_message;
11use crate::utils::time::{to_local, ymd};
12use leptos::prelude::*;
13use leptos::task::spawn_local;
14
15fn date(at: Option<chrono::DateTime<chrono::Utc>>, none: &str) -> String {
16    at.map_or_else(|| none.to_string(), |d| ymd(&to_local(d), '-'))
17}
18
19#[component]
20pub(super) fn TokenTable(
21    #[prop(into)] tokens: Signal<Option<Vec<McpTokenSummary>>>,
22    #[prop(into)] failed: Signal<bool>,
23    #[prop(into)] on_retry: Callback<()>,
24    #[prop(into)] on_open: Callback<OpenToken>,
25    #[prop(into)] on_edit: Callback<McpTokenSummary>,
26    #[prop(into)] on_changed: Callback<()>,
27) -> impl IntoView {
28    let toast = use_toast();
29    let revoking = RwSignal::new(None::<McpTokenSummary>);
30    let confirm = RwSignal::new(false);
31    let busy = RwSignal::new(None::<String>);
32
33    let open = move |token: McpTokenSummary| {
34        busy.set(Some(token.id.clone()));
35        spawn_local(async move {
36            match reveal_mcp_token(token.id.clone()).await {
37                Ok(Some(plaintext)) => {
38                    let _ = on_open.try_run(OpenToken {
39                        summary: token,
40                        plaintext,
41                        created: false,
42                    });
43                }
44                Ok(None) => toast.error("这个令牌已失效,无法查看"),
45                Err(e) => toast.error(failure_message("读取令牌失败", &e)),
46            }
47            let _ = busy.try_set(None);
48        });
49    };
50    let revoke = move |_| {
51        let Some(token) = revoking.get_untracked() else {
52            return;
53        };
54        spawn_local(async move {
55            match revoke_mcp_token(token.id.clone()).await {
56                Ok(()) => {
57                    toast.success(format!("已吊销「{}」", token.name));
58                    let _ = on_changed.try_run(());
59                }
60                Err(e) => toast.error(failure_message("吊销失败", &e)),
61            }
62        });
63    };
64
65    let row = move |token: McpTokenSummary| {
66        let active = is_active(&token);
67        let state = if token.revoked_at.is_some() {
68            Some("已吊销")
69        } else if !active {
70            Some("已过期")
71        } else {
72            None
73        };
74        let mut items = vec![
75            MenuItem::new("open", "接入配置").icon(IconName::Plug),
76            MenuItem::new("edit", "编辑权限").icon(IconName::Shield),
77            MenuItem::new("revoke", "吊销")
78                .icon(IconName::Trash)
79                .danger()
80                .separated(),
81        ];
82        if !active {
83            items = items
84                .into_iter()
85                .map(|i| i.disabled("令牌已失效"))
86                .collect();
87        }
88        let busy_id = token.id.clone();
89        let t = token.clone();
90        let sub = match state {
91            Some(state) => view! { <StatusDot label=state/> }.into_any(),
92            None => format!("创建于 {}", date(Some(token.created_at), "")).into_any(),
93        };
94        view! {
95            <tr
96                data-inactive=(!active).then_some("true")
97                aria-busy=move || busy.with(|b| b.as_ref() == Some(&busy_id)).then_some("true")
98            >
99                <td>
100                    <div class="mcp-token-name">{token.name.clone()}</div>
101                    <div class="mcp-token-sub">{sub}</div>
102                </td>
103                <td>
104                    <span class="mcp-scope-pill" data-scope=token.scope.as_str()>
105                        {token.scope.as_str()}
106                    </span>
107                </td>
108                <td class="mcp-col-notes">{notes_desc(&token)}</td>
109                <td class="admin-table-muted">{date(token.expires_at, "永不过期")}</td>
110                <td class="admin-table-muted mcp-col-used">{date(token.last_used_at, "从未使用")}</td>
111                <td class="admin-table-actions">
112                    <Menu
113                        label="令牌操作"
114                        items=items
115                        on_select=move |id: &'static str| match id {
116                            "open" => open(t.clone()),
117                            "edit" => on_edit.run(t.clone()),
118                            "revoke" => {
119                                revoking.set(Some(t.clone()));
120                                confirm.set(true);
121                            }
122                            _ => {}
123                        }
124                    />
125                </td>
126            </tr>
127        }
128    };
129
130    view! {
131        <section class="mcp-tokens" aria-labelledby="mcp-tokens-h">
132            <h2 id="mcp-tokens-h" class="mcp-section-title">
133                "已签发的令牌"
134            </h2>
135            {move || {
136                if failed.get() {
137                    return view! {
138                        <LoadError
139                            title="令牌列表加载失败"
140                            on_retry=move |_| on_retry.run(())
141                        />
142                    }
143                        .into_any();
144                }
145                match tokens.get() {
146                    Some(list) if list.is_empty() => {
147                        view! {
148                            <div class="admin-panel">
149                                <EmptyState
150                                    icon=IconName::KeyRound
151                                    title="还没有令牌"
152                                    description="新建一个令牌,把它写进 AI 客户端的配置即可连接。"
153                                />
154                            </div>
155                        }
156                            .into_any()
157                    }
158                    Some(list) => {
159                        view! {
160                            <div class="admin-panel admin-table-wrap">
161                                <table class="admin-table mcp-table">
162                                    <thead>
163                                        <tr>
164                                            <th>"名称"</th>
165                                            <th>"文章权限"</th>
166                                            <th class="mcp-col-notes">"笔记授权"</th>
167                                            <th>"有效期至"</th>
168                                            <th class="mcp-col-used">"最近使用"</th>
169                                            <th class="admin-table-actions">
170                                                <span class="sr-only">"操作"</span>
171                                            </th>
172                                        </tr>
173                                    </thead>
174                                    <tbody>{list.into_iter().map(row).collect_view()}</tbody>
175                                </table>
176                            </div>
177                        }
178                            .into_any()
179                    }
180                    None => {
181                        view! {
182                            <div class="admin-panel" aria-hidden="true">
183                                {(0..3)
184                                    .map(|_| {
185                                        view! {
186                                            <div class="mcp-skeleton-row">
187                                                <SkeletonBox class="h-4 w-40 rounded"/>
188                                                <SkeletonBox class="h-4 w-16 rounded"/>
189                                                <SkeletonBox class="h-4 w-24 rounded"/>
190                                            </div>
191                                        }
192                                    })
193                                    .collect_view()}
194                            </div>
195                        }
196                            .into_any()
197                    }
198                }
199            }}
200        </section>
201        <ConfirmDialog
202            visible=confirm
203            title="吊销这个令牌?"
204            description=move || {
205                format!(
206                    "使用「{}」的客户端会立刻无法连接,吊销后不能恢复。",
207                    revoking.get().map(|t| t.name).unwrap_or_default(),
208                )
209            }
210            confirm_label="吊销"
211            icon=IconName::KeyRound
212            on_confirm=revoke
213        />
214    }
215}