yggdrasil/pages/admin/mcp/
tokens.rs1use 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}