Skip to content
Clone

feat(space): hand out scoped space tokens

A space can mint a short-lived token that authenticates as a per-space bot:
the MCP tools and the Git HTTP endpoints accept it for repositories inside
that one space and reject it everywhere else, so an external runtime can work
on a space without a user password or a global key.

Invite mails now link to the SPA accept page and carry the space hint, and
space settings gained the cleanup view.
ZhenYi committed
parent aaaaf4c commit a7d5f14 Browse files

25 files changed

+1161 −17

File tree 25
Modified lib/export/src/authz.rs View lib/export/src/authz.rs
@@ -1,8 +1,10 @@
1use std::sync::OnceLock;
1use std::collections::HashSet;
2use std::sync::{OnceLock, RwLock};
23
34use uuid::Uuid;
45
56static SYSTEM_UID: OnceLock<Uuid> = OnceLock::new();
7static SPACE_BOTS: OnceLock<RwLock<HashSet<Uuid>>> = OnceLock::new();
68
79/// Register the platform system actor (AI bot) that MCP internal keys act as.
810/// Once set, every authorization check treats this uid as the owner of every
@@ -12,10 +14,29 @@
1214 let _ = SYSTEM_UID.set(uid);
1315}
1416
17/// Register a per-space bot that just signed a scoped top-level credential.
18/// Like [`set_system_uid`], registered bots pass every owner-level check; the
19/// credential layer is responsible for binding them to a single space (MCP
20/// tool dispatch and the Git HTTP auth entry points both enforce the bind).
21/// Space bots have no password, no sessions and no access keys, so this uid
22/// can only be reached with the token that registered it.
23pub fn register_space_bot(uid: Uuid) {
24 let bots = SPACE_BOTS.get_or_init(|| RwLock::new(HashSet::new()));
25 if let Ok(mut bots) = bots.write() {
26 bots.insert(uid);
27 }
28}
29
1530pub fn system_uid() -> Option<Uuid> {
1631 SYSTEM_UID.get().copied()
1732}
1833
1934pub fn is_system_uid(uid: Uuid) -> bool {
20 SYSTEM_UID.get().is_some_and(|value| *value == uid)
35 if SYSTEM_UID.get().is_some_and(|value| *value == uid) {
36 return true;
37 }
38 SPACE_BOTS
39 .get()
40 .and_then(|bots| bots.read().ok().map(|bots| bots.contains(&uid)))
41 .unwrap_or(false)
2142}
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -13,6 +13,7 @@
1313pub mod rest;
1414pub mod serde_datetime;
1515pub mod serde_patch;
16pub mod space_token;
1617pub mod storage;
1718pub mod telemetry;
1819pub mod urls;
Added lib/export/src/space_token.rs View lib/export/src/space_token.rs
@@ -0,0 +1,102 @@
1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6use crate::error::ExportError;
7use crate::redis::{self, AppRedis};
8
9/// Space top-level tokens are opaque short-lived credentials bound to a single
10/// space. They are issued through `mcp_api::space_token` (a library-only
11/// service, never an HTTP route or MCP tool) and verified here so that both
12/// the MCP endpoint and the Git HTTP endpoint can accept the same token.
13pub const TOKEN_PREFIX: &str = "spt_";
14const KEY_PREFIX: &str = "mcp:space_token:";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SpaceTokenRecord {
18 pub token_id: Uuid,
19 pub space_id: Uuid,
20 pub space_name: String,
21 pub bot_uid: Uuid,
22 pub secret_hash: String,
23 #[serde(default)]
24 pub issued_by: Option<String>,
25 pub created_at_unix: i64,
26 pub expires_at_unix: i64,
27}
28
29pub fn looks_like(token: &str) -> bool {
30 token.starts_with(TOKEN_PREFIX)
31}
32
33pub fn key(token_id: Uuid) -> String {
34 format!("{KEY_PREFIX}{token_id}")
35}
36
37pub fn parse(token: &str) -> Option<(Uuid, &str)> {
38 let rest = token.strip_prefix(TOKEN_PREFIX)?;
39 let (id, secret) = rest.split_once('.')?;
40 if secret.is_empty() {
41 return None;
42 }
43 let id = Uuid::parse_str(id).ok()?;
44 Some((id, secret))
45}
46
47pub fn hash_secret(secret: &str) -> String {
48 let digest = Sha256::digest(secret.as_bytes());
49 digest.iter().map(|byte| format!("{byte:02x}")).collect()
50}
51
52pub async fn store(
53 redis: &AppRedis,
54 record: &SpaceTokenRecord,
55 ttl_secs: u64,
56) -> Result<(), ExportError> {
57 let value = serde_json::to_string(record)
58 .map_err(|error| ExportError::InvalidConfig(format!("space token encode: {error}")))?;
59 redis::set_string(redis, &key(record.token_id), &value, ttl_secs.max(1)).await?;
60 Ok(())
61}
62
63pub async fn verify(
64 redis: &AppRedis,
65 token: &str,
66) -> Result<Option<SpaceTokenRecord>, ExportError> {
67 let Some((token_id, secret)) = parse(token) else {
68 return Ok(None);
69 };
70 let Some(raw) = redis::get_string(redis, &key(token_id)).await? else {
71 return Ok(None);
72 };
73 let Ok(record) = serde_json::from_str::<SpaceTokenRecord>(&raw) else {
74 return Ok(None);
75 };
76 if record.expires_at_unix <= OffsetDateTime::now_utc().unix_timestamp() {
77 return Ok(None);
78 }
79 let computed = hash_secret(secret);
80 if !constant_time_eq(computed.as_bytes(), record.secret_hash.as_bytes()) {
81 return Ok(None);
82 }
83 Ok(Some(record))
84}
85
86/// Deletes the token; returns whether it still existed.
87pub async fn revoke(redis: &AppRedis, token_id: Uuid) -> Result<bool, ExportError> {
88 let existed = redis::exists(redis, &key(token_id)).await?;
89 redis::delete(redis, &key(token_id)).await?;
90 Ok(existed)
91}
92
93fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
94 if a.len() != b.len() {
95 return false;
96 }
97 let mut diff: u8 = 0;
98 for (x, y) in a.iter().zip(b.iter()) {
99 diff |= x ^ y;
100 }
101 diff == 0
102}
Modified lib/export/Cargo.toml View lib/export/Cargo.toml
@@ -13,6 +13,7 @@
1313imaging = { workspace = true }
1414axum = { workspace = true }
1515serde = { workspace = true, features = ["derive"] }
16sha2 = { workspace = true }
1617tracing = { workspace = true }
1718tracing-subscriber = { workspace = true }
1819opentelemetry = { workspace = true }
Modified lib/git/src/share/access.rs View lib/git/src/share/access.rs
@@ -110,6 +110,15 @@
110110 if let Some(token) = auth
111111 .token
112112 .as_deref()
113 .filter(|token| export::space_token::looks_like(token))
114 {
115 return verify_space_bot_token(svc, token, ctx)
116 .await?
117 .ok_or(GitError::Unauthorized);
118 }
119 if let Some(token) = auth
120 .token
121 .as_deref()
113122 .filter(|token| token.starts_with("ci_"))
114123 {
115124 return verify_ci_job_token(svc, token, ctx, true).await;
@@ -119,6 +128,34 @@
119128 Ok(uid)
120129}
121130
131/// Temporary space top-level tokens authenticate as the per-space bot and are
132/// valid only for repositories inside the space they were issued for. The
133/// token itself carries every owner-level right in that space, so no further
134/// repo ACL is consulted (mismatched tokens are rejected, not downgraded).
135async fn verify_space_bot_token(
136 svc: &GitService,
137 token: &str,
138 ctx: &GitContext,
139) -> Result<Option<Uuid>, GitError> {
140 if !export::space_token::looks_like(token) {
141 return Ok(None);
142 }
143 let record = export::space_token::verify(&svc.state.redis, token)
144 .await
145 .map_err(|error| GitError::Internal(format!("space token verification failed: {error}")))?;
146 let Some(record) = record else {
147 return Err(GitError::Unauthorized);
148 };
149 let space = find_space_for_repo(svc.db_read(), &ctx.model).await?;
150 if space.id != record.space_id {
151 return Err(GitError::Forbidden);
152 }
153 // Register so nested owner-level checks in this process also pass; the
154 // uid is only ever reachable through a token bound to one space.
155 export::authz::register_space_bot(record.bot_uid);
156 Ok(Some(record.bot_uid))
157}
158
122159async fn verify_ci_job_token(
123160 svc: &GitService,
124161 token: &str,
@@ -167,6 +204,13 @@
167204 if let Some(token) = auth
168205 .token
169206 .as_deref()
207 .filter(|token| export::space_token::looks_like(token))
208 {
209 return verify_space_bot_token(svc, token, ctx).await;
210 }
211 if let Some(token) = auth
212 .token
213 .as_deref()
170214 .filter(|token| token.starts_with("ci_"))
171215 {
172216 return verify_ci_job_token(svc, token, ctx, false).await.map(Some);
@@ -192,6 +236,15 @@
192236 auth: &BasicAuth,
193237 ctx: &GitContext,
194238) -> Result<Uuid, GitError> {
239 if let Some(token) = auth
240 .token
241 .as_deref()
242 .filter(|token| export::space_token::looks_like(token))
243 {
244 return verify_space_bot_token(svc, token, ctx)
245 .await?
246 .ok_or(GitError::Unauthorized);
247 }
195248 if auth
196249 .token
197250 .as_deref()
Modified lib/mcp-api/src/tools/args.rs View lib/mcp-api/src/tools/args.rs
@@ -539,6 +539,9 @@
539539 pub name: String,
540540 #[serde(default)]
541541 pub parent_dir_id: Option<Uuid>,
542 /// Number prefix for the board's issues (`CAD` renders as `CAD-12`).
543 #[serde(default)]
544 pub prefix: Option<String>,
542545 #[serde(default)]
543546 pub icon: Option<String>,
544547 #[serde(default)]
@@ -551,6 +554,9 @@
551554 pub board_id: Uuid,
552555 #[serde(default)]
553556 pub name: Option<String>,
557 /// Number prefix; `""` clears it.
558 #[serde(default)]
559 pub prefix: Option<String>,
554560 #[serde(default)]
555561 pub icon: Option<String>,
556562 #[serde(default)]
Modified lib/mcp-api/src/tools/issue.rs View lib/mcp-api/src/tools/issue.rs
@@ -1,7 +1,9 @@
11use issues::services::boards::{CreateBoardCmd, ListBoardQuery, UpdateBoardCmd};
22use issues::services::comments::{CreateCommentCmd, ListCommentQuery, UpdateCommentCmd};
33use issues::services::dependencies::CreateDependencyCmd;
4use issues::services::issues::{CreateIssueCmd, IssueState, ListIssueQuery, UpdateIssueCmd};
4use issues::services::issues::{
5 CreateIssueCmd, IssueState, ListIssueQuery, UpdateIssueCmd, priority_query_key,
6};
57use issues::services::labels::{CreateLabelCmd, ListLabelQuery, UpdateLabelCmd};
68use issues::services::members::{CreateIssueMemberCmd, ListIssueMemberQuery};
79use issues::services::milestones::{CreateMilestoneCmd, ListMilestoneQuery, UpdateMilestoneCmd};
@@ -78,6 +80,7 @@
7880 let cmd = CreateBoardCmd {
7981 name: args.name,
8082 parent_dir_id: args.parent_dir_id,
83 prefix: args.prefix,
8184 icon: args.icon,
8285 description: args.description,
8386 };
@@ -101,6 +104,7 @@
101104 }
102105 let cmd = UpdateBoardCmd {
103106 name: args.name,
107 prefix: args.prefix.map(Some),
104108 icon: args.icon.map(Some),
105109 description: args.description.map(Some),
106110 };
@@ -154,10 +158,10 @@
154158 let query = ListIssueQuery {
155159 state,
156160 author_id: args.author_id,
157 milestone_id: args.milestone_id,
158 label_id: args.label_id,
159 assignee_id: args.assignee_id,
160 priority,
161 milestone_id: args.milestone_id.map(|id| id.to_string()),
162 label_id: args.label_id.map(|id| id.to_string()),
163 assignee_id: args.assignee_id.map(|id| id.to_string()),
164 priority: priority.map(|value| priority_query_key(value).to_string()),
161165 sort: args.sort,
162166 q: args.q,
163167 limit: args.limit,
@@ -231,6 +235,7 @@
231235 due_at,
232236 assignee_ids: args.assignee_ids,
233237 label_ids: args.label_ids,
238 attachment_ids: Vec::new(),
234239 };
235240 match services
236241 .issues
Modified lib/mcp-api/src/tools/me.rs View lib/mcp-api/src/tools/me.rs
@@ -17,6 +17,7 @@
1717 "source": match principal.source {
1818 crate::auth::PrincipalSource::UserKey => "user_key",
1919 crate::auth::PrincipalSource::InternalKey => "internal_key",
20 crate::auth::PrincipalSource::SpaceToken => "space_token",
2021 },
2122 }
2223 })),
Modified lib/mcp-api/src/tools/repo.rs View lib/mcp-api/src/tools/repo.rs
@@ -100,6 +100,10 @@
100100 ai_review_on_push: args.ai_review_on_push,
101101 ai_summary_on_push: args.ai_summary_on_push,
102102 ai_review_on_pull: args.ai_review_on_pull,
103 allow_pull_requests: None,
104 pipelines_public: None,
105 ai_summary_public: None,
106 tab_order: None,
103107 };
104108 match services
105109 .git
Modified lib/mcp-api/src/auth.rs View lib/mcp-api/src/auth.rs
@@ -20,6 +20,7 @@
2020pub enum PrincipalSource {
2121 UserKey,
2222 InternalKey,
23 SpaceToken,
2324}
2425
2526#[derive(Debug, Clone)]
@@ -28,6 +29,9 @@
2829 pub scope: AccessKeyScope,
2930 pub full_access: bool,
3031 pub source: PrincipalSource,
32 /// `Some((space_id, lower_name))` for temporary space top-level tokens:
33 /// every tool call must stay inside that single space.
34 pub bound_space: Option<(Uuid, String)>,
3135}
3236
3337impl Principal {
@@ -73,6 +77,27 @@
7377 scope: AccessKeyScope::Write,
7478 full_access: true,
7579 source: PrincipalSource::InternalKey,
80 bound_space: None,
81 }
82 } else if export::space_token::looks_like(&token) {
83 match export::space_token::verify(&runtime.services().state.redis, &token).await {
84 Ok(Some(record)) => {
85 // The bot passes every owner-level authorization check; the
86 // space binding below is what keeps it scoped to one space.
87 export::authz::register_space_bot(record.bot_uid);
88 Principal {
89 uid: record.bot_uid,
90 scope: AccessKeyScope::Write,
91 full_access: true,
92 source: PrincipalSource::SpaceToken,
93 bound_space: Some((record.space_id, record.space_name)),
94 }
95 }
96 Ok(None) => return unauthorized("访问密钥无效或已过期"),
97 Err(error) => {
98 tracing::warn!(%error, "mcp space token verification failed");
99 return unavailable("鉴权服务暂不可用");
100 }
76101 }
77102 } else {
78103 match runtime.services().auth.verify_access_token(&token).await {
@@ -81,6 +106,7 @@
81106 scope: verified.scope,
82107 full_access: false,
83108 source: PrincipalSource::UserKey,
109 bound_space: None,
84110 },
85111 Ok(None) => return unauthorized("访问密钥无效或已过期"),
86112 Err(error) => {
Modified lib/mcp-api/src/lib.rs View lib/mcp-api/src/lib.rs
@@ -1,6 +1,7 @@
11pub mod catalog;
22pub mod error;
33pub mod settings;
4pub mod space_token;
45
56mod auth;
67mod net;
Modified lib/mcp-api/src/server.rs View lib/mcp-api/src/server.rs
@@ -69,8 +69,16 @@
6969 request: CallToolRequestParams,
7070 context: RequestContext<RoleServer>,
7171 ) -> Result<CallToolResponse, ErrorData> {
72 self.principal(&context)?;
72 let principal = self.principal(&context)?;
7373 let name = request.name.to_string();
74 if let Some((_, bound_name)) = &principal.bound_space
75 && !bound_space_allows(&request, bound_name)
76 {
77 return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
78 "该访问密钥仅允许操作 space {bound_name}"
79 ))])
80 .into());
81 }
7482 let disabled = settings::read_disabled(&self.services.state).await;
7583 if disabled.contains(&name) {
7684 return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
@@ -82,3 +90,28 @@
8290 self.tool_router.call(call).await
8391 }
8492}
93
94/// Temporary space tokens may only reach tools that address their single bound
95/// space. `me_get` is the one space-less tool they may use; everything else
96/// must carry a top-level `space`/`space_name` argument equal to the binding.
97fn bound_space_allows(request: &CallToolRequestParams, bound_name: &str) -> bool {
98 if request.name.as_ref() == "me_get" {
99 return true;
100 }
101 let Some(arguments) = request.arguments.as_ref() else {
102 return false;
103 };
104 let mut matched = false;
105 for key in ["space", "space_name"] {
106 if let Some(value) = arguments.get(key) {
107 let Some(value) = value.as_str() else {
108 return false;
109 };
110 if !value.trim().eq_ignore_ascii_case(bound_name) {
111 return false;
112 }
113 matched = true;
114 }
115 }
116 matched
117}
Added lib/mcp-api/src/space_token.rs View lib/mcp-api/src/space_token.rs
@@ -0,0 +1,248 @@
1use export::AppState;
2use export::space_token::SpaceTokenRecord;
3use models::space::spaces;
4use models::users::user::{self, ProfileVisibility};
5use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
6use serde::{Deserialize, Serialize};
7use space::errors::SpaceError;
8use space::services::members::{MemberActivationDefaults, activate_member_row};
9use space::services::service::SpaceService;
10use space::share::lookup::lookup_space_by_name;
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14/// Library-only service for issuing temporary top-level tokens for a space.
15///
16/// Deliberately not an HTTP route and not an MCP tool: it is only reachable
17/// from Rust code that links this crate (`mcp_api::space_token::issue/revoke`).
18/// The token authenticates as a login-prohibited per-space bot that is treated
19/// as the platform system actor, but the MCP tool dispatch and the Git HTTP
20/// auth entry points both restrict it to the single space the token was issued
21/// for.
22pub const DEFAULT_TTL_SECS: u64 = 15 * 60;
23pub const MIN_TTL_SECS: u64 = 60;
24pub const MAX_TTL_SECS: u64 = 24 * 60 * 60;
25
26const BOT_USERNAME_SUFFIX: &str = "-bot";
27const MAX_USERNAME_LEN: usize = 64;
28
29#[derive(Debug, thiserror::Error)]
30pub enum SpaceTokenError {
31 #[error("space not found")]
32 SpaceNotFound,
33 #[error("username {0:?} is already taken by a non-bot account")]
34 BotNameConflict(String),
35 #[error(transparent)]
36 Space(#[from] SpaceError),
37 #[error(transparent)]
38 Database(#[from] sea_orm::DbErr),
39 #[error(transparent)]
40 Export(#[from] export::ExportError),
41}
42
43impl From<sea_orm::TransactionError<SpaceTokenError>> for SpaceTokenError {
44 fn from(error: sea_orm::TransactionError<SpaceTokenError>) -> Self {
45 match error {
46 sea_orm::TransactionError::Connection(db_err) => Self::Database(db_err),
47 sea_orm::TransactionError::Transaction(error) => error,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct IssueSpaceTokenCmd {
54 pub space: String,
55 #[serde(default)]
56 pub ttl_secs: Option<u64>,
57 #[serde(default)]
58 pub issued_by: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct IssuedSpaceToken {
63 pub token: String,
64 pub token_id: Uuid,
65 pub expires_at: OffsetDateTime,
66 pub space_id: Uuid,
67 pub space_name: String,
68 pub bot_uid: Uuid,
69 pub bot_username: String,
70}
71
72pub async fn issue(
73 state: &AppState,
74 cmd: IssueSpaceTokenCmd,
75) -> Result<IssuedSpaceToken, SpaceTokenError> {
76 let ttl = cmd
77 .ttl_secs
78 .unwrap_or(DEFAULT_TTL_SECS)
79 .clamp(MIN_TTL_SECS, MAX_TTL_SECS);
80 let service = SpaceService::new(state.clone());
81 let space = match lookup_space_by_name(&service, cmd.space.trim()).await {
82 Ok(space) => space,
83 Err(SpaceError::NotFound) => return Err(SpaceTokenError::SpaceNotFound),
84 Err(error) => return Err(SpaceTokenError::Space(error)),
85 };
86
87 let username = bot_username(&space);
88 let bot_uid = resolve_space_bot(state, &space, &username).await?;
89 ensure_space_membership(state, space.id, bot_uid).await?;
90
91 let token_id = Uuid::now_v7();
92 let secret = random_secret();
93 let token = format!("{}{token_id}.{secret}", export::space_token::TOKEN_PREFIX);
94 let now = OffsetDateTime::now_utc();
95 let expires_at = now + time::Duration::seconds(ttl as i64);
96 let record = SpaceTokenRecord {
97 token_id,
98 space_id: space.id,
99 space_name: space.lower_name.clone(),
100 bot_uid,
101 secret_hash: export::space_token::hash_secret(&secret),
102 issued_by: cmd.issued_by.clone(),
103 created_at_unix: now.unix_timestamp(),
104 expires_at_unix: expires_at.unix_timestamp(),
105 };
106 export::space_token::store(&state.redis, &record, ttl).await?;
107
108 tracing::info!(
109 space_id = %space.id,
110 space = %space.lower_name,
111 bot_uid = %bot_uid,
112 token_id = %token_id,
113 ttl_secs = ttl,
114 issued_by = cmd.issued_by.as_deref().unwrap_or("-"),
115 "issued a temporary space top-level token"
116 );
117
118 Ok(IssuedSpaceToken {
119 token,
120 token_id,
121 expires_at,
122 space_id: space.id,
123 space_name: space.lower_name,
124 bot_uid,
125 bot_username: username,
126 })
127}
128
129/// Deletes a token before its TTL elapses; returns whether it still existed.
130pub async fn revoke(state: &AppState, token_id: Uuid) -> Result<bool, SpaceTokenError> {
131 Ok(export::space_token::revoke(&state.redis, token_id).await?)
132}
133
134pub async fn verify(
135 state: &AppState,
136 token: &str,
137) -> Result<Option<SpaceTokenRecord>, SpaceTokenError> {
138 Ok(export::space_token::verify(&state.redis, token).await?)
139}
140
141fn bot_username(space: &spaces::Model) -> String {
142 let base = format!("{}{BOT_USERNAME_SUFFIX}", space.lower_name);
143 if base.len() <= MAX_USERNAME_LEN {
144 return base;
145 }
146 let hash = export::space_token::hash_secret(&space.lower_name);
147 let keep = MAX_USERNAME_LEN - 1 - 8 - BOT_USERNAME_SUFFIX.len();
148 let prefix: String = space.lower_name.chars().take(keep).collect();
149 format!("{prefix}-{}{BOT_USERNAME_SUFFIX}", &hash[..8])
150}
151
152fn random_secret() -> String {
153 let bytes: [u8; 32] = rand::random();
154 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
155}
156
157/// Look up the space bot by its deterministic username and create it only when
158/// it does not exist yet. The account is kept forever, so this is a plain
159/// resolve-or-create; a racing insert falls back to a re-read.
160async fn resolve_space_bot(
161 state: &AppState,
162 space: &spaces::Model,
163 username: &str,
164) -> Result<Uuid, SpaceTokenError> {
165 let lower = username.to_lowercase();
166 let db = state.database.write();
167 if let Some(row) = user::Entity::find()
168 .filter(user::Column::LowerName.eq(&lower))
169 .one(db)
170 .await?
171 {
172 if !row.bot {
173 return Err(SpaceTokenError::BotNameConflict(username.to_string()));
174 }
175 return Ok(row.uid);
176 }
177
178 let uid = Uuid::now_v7();
179 let now = OffsetDateTime::now_utc();
180 let created = user::ActiveModel {
181 uid: Set(uid),
182 username: Set(username.to_string()),
183 lower_name: Set(lower.clone()),
184 display_name: Set(format!("{} Bot", space.name)),
185 avatar_url: Set(String::new()),
186 is_active: Set(true),
187 is_restricted: Set(false),
188 prohibit_login: Set(true),
189 visibility: Set(ProfileVisibility::Public),
190 bot: Set(true),
191 system: Set(true),
192 last_login_at: Set(None),
193 last_login_ip_hash: Set(None),
194 version: Set(1),
195 passwd_hash: Set(None),
196 password_last_changed_at: Set(None),
197 passwd_version: Set(None),
198 totp_encrypted_secret: Set(None),
199 totp_enabled: Set(false),
200 totp_last_used_at: Set(None),
201 totp_last_counter: Set(None),
202 timezone: Set(None),
203 locale: Set(None),
204 language: Set(None),
205 created_at: Set(now),
206 updated_at: Set(now),
207 }
208 .insert(db)
209 .await;
210 match created {
211 Ok(row) => Ok(row.uid),
212 Err(_) => user::Entity::find()
213 .filter(user::Column::LowerName.eq(&lower))
214 .one(db)
215 .await?
216 .map(|row| row.uid)
217 .ok_or_else(|| sea_orm::DbErr::RecordNotFound("space bot user".to_string()).into()),
218 }
219}
220
221/// Idempotent: inserts the membership row on first use, revives a left/removed
222/// row, and bumps `nums_members` exactly once. Skips the MANAGE_MEMBERS / 2FA
223/// checks `add_member` performs because the bot is a system-equivalent actor.
224async fn ensure_space_membership(
225 state: &AppState,
226 space_id: Uuid,
227 bot_uid: Uuid,
228) -> Result<(), SpaceTokenError> {
229 let now = OffsetDateTime::now_utc();
230 state
231 .database
232 .primary()
233 .transaction::<_, bool, SpaceTokenError>(move |txn| {
234 Box::pin(async move {
235 activate_member_row(
236 txn,
237 space_id,
238 bot_uid,
239 now,
240 MemberActivationDefaults::default(),
241 )
242 .await?;
243 Ok(true)
244 })
245 })
246 .await?;
247 Ok(())
248}
Modified lib/mcp-api/Cargo.toml View lib/mcp-api/Cargo.toml
@@ -19,11 +19,13 @@
1919axum = { workspace = true }
2020base64 = { workspace = true }
2121ipnet = { workspace = true }
22rand = { workspace = true }
2223rmcp = { workspace = true }
2324sea-orm = { workspace = true }
2425serde = { workspace = true, features = ["derive"] }
2526serde_json = { workspace = true }
2627sha2 = { workspace = true }
28thiserror = { workspace = true }
2729time = { workspace = true }
2830tokio = { workspace = true }
2931tracing = { workspace = true }
Modified lib/space/src/services/invites.rs View lib/space/src/services/invites.rs
@@ -298,9 +298,12 @@
298298 // invitee uid is the account signal when it exists.
299299 let locale = self.recipient_locale(target_uid).await;
300300 let subject = i18n::catalog::space_invite_subject(locale, &space.name);
301 // 邮件链接落在 SPA 的接受页:它校验邀请码,并带着 `space` 提示把
302 // 新成员直接送进刚加入的空间。
301303 let accept_url = format!(
302 "{}/spaces/invites/accept?code={accept_code}",
303 self.config().web.web_base_url
304 "{}/spaces/invites/accept?code={accept_code}&space={}",
305 self.config().web.web_base_url,
306 space.lower_name
304307 );
305308 let text = i18n::catalog::space_invite_text(locale, &space.name, &accept_url);
306309 let html = format!(
Modified web/src/features/space-management/model/api.ts View web/src/features/space-management/model/api.ts
@@ -17,6 +17,7 @@
1717 AppResponseData_UserPreferencesDto,
1818 AppResponseData_Vec_DirNodeDto,
1919 AppResponseData_Vec_MySpaceDto,
20 CreateAgentCollectionCmd,
2021 CreateBoardCmd,
2122 CreateDirCmd,
2223 CreateInviteCmd,
@@ -92,12 +93,25 @@
9293 Apis.space.create_invite({ pathParams: { space_name: spaceName }, data: cmd }),
9394 )
9495
96/**
97 * Accept an invitation by the code its email carried. Mirrors the notification
98 * inbox's wrapper: features may not import each other, so the two copies stay
99 * deliberately small.
100 */
101export const acceptSpaceInvite = (code: string) =>
102 unwrapApi<unknown>(Apis.space.accept_invite({ data: { code } }))
103
95104export const createRepo = (spaceName: string, cmd: CreateRepoCmd) =>
96105 unwrapApi<unknown>(Apis.repos.create_repo({ pathParams: { space_name: spaceName }, data: cmd }))
97106
98107export const createBoard = (spaceName: string, cmd: CreateBoardCmd) =>
99108 unwrapApi<unknown>(Apis.issues.create_board({ pathParams: { space_name: spaceName }, data: cmd }))
100109
110export const createAgentCollection = (spaceName: string, cmd: CreateAgentCollectionCmd) =>
111 unwrapApi<unknown>(
112 Apis.agents.create_agent_collection({ pathParams: { space_name: spaceName }, data: cmd }),
113 )
114
101115export const createRoom = (spaceName: string, cmd: CreateRoomCmd) =>
102116 unwrapApi<unknown>(
103117 Apis.chat.create_room({ pathParams: { space_name: spaceName }, data: cmd }),
Modified web/src/features/space-management/model/space-admin.ts View web/src/features/space-management/model/space-admin.ts
@@ -21,6 +21,7 @@
2121 AppResponseData_MemberDto,
2222 AppResponseData_SpaceImageDto,
2323 AppResponseData_UserPublic,
24 AppResponseData_Vec_CleanupObjectDto,
2425 AppResponseData_Vec_GroupOverwriteDto,
2526 AppResponseData_Vec_UserOverwriteDto,
2627 AssignGroupCmd,
@@ -47,6 +48,11 @@
4748export type DirUserOverwrite = NonNullable<AppResponseData_Vec_UserOverwriteDto['data']>[number]
4849export type DirOverwrite = DirGroupOverwrite | DirUserOverwrite
4950export type PublicUser = NonNullable<AppResponseData_UserPublic['data']>
51
52/* ---------------------------------------------------------------- cleanup */
53
54/** 对象清理清单里的一行:目录节点或频道(见 webapi::cleanup)。 */
55export type CleanupObject = NonNullable<AppResponseData_Vec_CleanupObjectDto['data']>[number]
5056export type { MemberStatus, SpaceImageKind }
5157
5258const pageRows = async <T>(method: unknown): Promise<T[]> => {
@@ -377,3 +383,50 @@
377383/** Owner only, and soft: the space stops being visible but keeps its rows. */
378384export const deleteSpace = (spaceName: string) =>
379385 unwrapApi<null>(Apis.space.delete_space({ pathParams: { space_name: spaceName } }))
386
387/* ----------------------------------------------------------------- cleanup */
388
389export const listCleanupObjects = (spaceName: string) =>
390 rows<CleanupObject>(
391 Apis.cleanup.list_cleanup_objects({ pathParams: { space_name: spaceName } }),
392 )
393
394export const setCleanupVisibility = (
395 spaceName: string,
396 kind: string,
397 objectId: string,
398 isPrivate: boolean,
399) =>
400 unwrapApi<null>(
401 Apis.cleanup.set_cleanup_visibility({
402 pathParams: { space_name: spaceName, kind, object_id: objectId },
403 data: { is_private: isPrivate },
404 }),
405 )
406
407export const setCleanupArchived = (
408 spaceName: string,
409 kind: string,
410 objectId: string,
411 archived: boolean,
412) =>
413 unwrapApi<null>(
414 Apis.cleanup.set_cleanup_archive({
415 pathParams: { space_name: spaceName, kind, object_id: objectId },
416 data: { archived },
417 }),
418 )
419
420export const deleteCleanupObject = (spaceName: string, kind: string, objectId: string) =>
421 unwrapApi<null>(
422 Apis.cleanup.delete_cleanup_object({
423 pathParams: { space_name: spaceName, kind, object_id: objectId },
424 }),
425 )
426
427export const clearCleanupOverwrites = (spaceName: string, kind: string, objectId: string) =>
428 unwrapApi<null>(
429 Apis.cleanup.clear_cleanup_overwrites({
430 pathParams: { space_name: spaceName, kind, object_id: objectId },
431 }),
432 )
No newline at end of file
Added web/src/features/space-management/ui/space-settings/cleanup-section.tsx View web/src/features/space-management/ui/space-settings/cleanup-section.tsx
@@ -0,0 +1,240 @@
1/**
2 * 对象清理(危险区,仅空间所有者)。
3 *
4 * 一张扁平清单覆盖空间里的两棵树:目录节点(目录 / 仓库 / 问题集 / 智能体集)
5 * 与频道(文字 / 语音 / 舞台 …)。每行给出与类型匹配的危险操作:
6 * 仓库可切换公开/私有并归档,频道可切换公开/私有或删除,问题集/智能体集可删除,
7 * 空目录可删除,带可见性覆盖的节点可一键恢复默认。删除一律走各模块自己的清理
8 * 逻辑(见 webapi::cleanup),所以这里只负责把危险讲清楚:每个破坏性动作都要
9 * 在确认框里报出对象路径。
10 */
11import { useMemo, useState } from 'react'
12import {
13 Alert,
14 Button,
15 EmptyNote,
16 SectionTitle,
17 SettingRow,
18 inputCls,
19 useSave,
20} from '@/shared/ui'
21import { spaceAdminKey, useAsync } from '@/shared/lib'
22import { useT, type MessageKey } from '@/shared/i18n'
23import {
24 clearCleanupOverwrites,
25 deleteCleanupObject,
26 listCleanupObjects,
27 setCleanupArchived,
28 setCleanupVisibility,
29 type CleanupObject,
30} from '../../model/space-admin'
31import type { SpaceSectionProps } from './props'
32
33const KIND_KEYS: Record<string, MessageKey> = {
34 dir: 'space.cleanup.kind.dir',
35 object: 'space.cleanup.kind.object',
36 repo: 'space.cleanup.kind.repo',
37 board: 'space.cleanup.kind.board',
38 collection: 'space.cleanup.kind.collection',
39 text: 'space.cleanup.kind.text',
40 voice: 'space.cleanup.kind.voice',
41 stage: 'space.cleanup.kind.stage',
42 category: 'space.cleanup.kind.category',
43 news: 'space.cleanup.kind.news',
44 guild_room: 'space.cleanup.kind.guildRoom',
45 thread: 'space.cleanup.kind.thread',
46}
47
48/** 支持公开/私有的类型:仓库与频道。 */
49const VISIBILITY_KINDS = new Set(['repo', 'text', 'voice', 'stage', 'category', 'news', 'guild_room', 'thread'])
50/** 可以删除的类型(仓库除外,它只能归档)。 */
51const DELETE_KINDS = new Set(['board', 'collection', 'text', 'voice', 'stage', 'category', 'news', 'guild_room', 'thread', 'dir', 'object'])
52/** 可见性覆盖存放在目录节点上。 */
53const OVERWRITE_KINDS = new Set(['dir', 'object', 'repo', 'board', 'collection'])
54
55export function CleanupSection({ spaceName, isOwner }: SpaceSectionProps) {
56 const { t } = useT()
57 const [tick, setTick] = useState(0)
58 const [query, setQuery] = useState('')
59 const [acting, setActing] = useState<string | null>(null)
60 const { run, busy, notice, error } = useSave()
61 const state = useAsync(() => listCleanupObjects(spaceName), [spaceName, tick], {
62 cacheKey: spaceAdminKey(spaceName, 'cleanup'),
63 })
64
65 const filtered = useMemo(() => {
66 const list = state.data ?? []
67 const needle = query.trim().toLowerCase()
68 if (!needle) return list
69 return list.filter(item =>
70 `${item.name} ${item.path} ${item.kind}`.toLowerCase().includes(needle),
71 )
72 }, [state.data, query])
73
74 if (!isOwner) {
75 return (
76 <section>
77 <SectionTitle title={t('space.settings.sections.cleanup')} sub={t('space.cleanup.ownerSub')} />
78 <Alert tone="info">{t('space.cleanup.notOwner')}</Alert>
79 </section>
80 )
81 }
82
83 const refresh = () => setTick(value => value + 1)
84
85 const act = (key: string, fn: () => Promise<unknown>, ok: string) => {
86 setActing(key)
87 void run(fn, ok).finally(() => setActing(null))
88 }
89
90 return (
91 <section>
92 <SectionTitle title={t('space.settings.sections.cleanup')} sub={t('space.cleanup.sub')} />
93 {notice && !error ? <Alert tone="success">{notice}</Alert> : null}
94 {error ? <Alert tone="error">{error}</Alert> : null}
95
96 <div className="mt-4 max-w-[420px]">
97 <input
98 className={inputCls}
99 value={query}
100 placeholder={t('space.cleanup.filterPlaceholder')}
101 onChange={event => setQuery(event.target.value)}
102 />
103 </div>
104
105 {state.loading && !state.data ? (
106 <EmptyNote>{t('ui.loading')}</EmptyNote>
107 ) : filtered.length === 0 ? (
108 <EmptyNote>{t('space.cleanup.empty')}</EmptyNote>
109 ) : (
110 <div className="mt-4 space-y-2">
111 {filtered.map(item => (
112 <CleanupRow
113 key={`${item.kind}:${item.id}`}
114 spaceName={spaceName}
115 item={item}
116 busy={busy || acting === `${item.kind}:${item.id}`}
117 onAct={act}
118 onChanged={refresh}
119 />
120 ))}
121 </div>
122 )}
123 </section>
124 )
125}
126
127function CleanupRow({
128 spaceName,
129 item,
130 busy,
131 onAct,
132 onChanged,
133}: {
134 spaceName: string
135 item: CleanupObject
136 busy: boolean
137 onAct: (key: string, fn: () => Promise<unknown>, ok: string) => void
138 onChanged: () => void
139}) {
140 const { t } = useT()
141 const rowKey = `${item.kind}:${item.id}`
142 const hasChildren = item.child_count > 0
143
144 const toggleVisibility = () =>
145 onAct(
146 rowKey,
147 async () => {
148 await setCleanupVisibility(spaceName, item.kind, item.id, !item.is_private)
149 onChanged()
150 },
151 t('space.cleanup.updated'),
152 )
153
154 const toggleArchive = () =>
155 onAct(
156 rowKey,
157 async () => {
158 await setCleanupArchived(spaceName, item.kind, item.id, !item.is_archived)
159 onChanged()
160 },
161 t('space.cleanup.updated'),
162 )
163
164 const remove = () => {
165 if (!window.confirm(t('space.cleanup.confirmDelete', { name: item.path || item.name }))) return
166 onAct(
167 rowKey,
168 async () => {
169 await deleteCleanupObject(spaceName, item.kind, item.id)
170 onChanged()
171 },
172 t('space.cleanup.deleted'),
173 )
174 }
175
176 const clearOverwrites = () => {
177 if (!window.confirm(t('space.cleanup.confirmClearOverwrites', { name: item.path || item.name }))) return
178 onAct(
179 rowKey,
180 async () => {
181 await clearCleanupOverwrites(spaceName, item.kind, item.id)
182 onChanged()
183 },
184 t('space.cleanup.overwritesCleared'),
185 )
186 }
187
188 const meta: string[] = [item.path]
189 if (item.kind === 'dir' || item.kind === 'object') {
190 meta.push(t('space.cleanup.children', { count: item.child_count }))
191 }
192 if (item.overwrite_count > 0) {
193 meta.push(t('space.cleanup.overwrites', { count: item.overwrite_count }))
194 }
195 if (item.is_archived) meta.push(t('space.cleanup.archived'))
196
197 return (
198 <div className="settings-card">
199 <SettingRow
200 lead={
201 <span className="rounded-md bg-shell-active px-2 py-0.5 text-[11px] font-semibold text-shell-ink">
202 {t(KIND_KEYS[item.kind] ?? 'space.cleanup.kind.object')}
203 </span>
204 }
205 title={item.name}
206 meta={meta.join(' · ')}
207 actions={
208 <>
209 {VISIBILITY_KINDS.has(item.kind) ? (
210 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={toggleVisibility}>
211 {item.is_private ? t('space.cleanup.makePublic') : t('space.cleanup.makePrivate')}
212 </Button>
213 ) : null}
214 {item.kind === 'repo' ? (
215 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={toggleArchive}>
216 {item.is_archived ? t('space.cleanup.unarchive') : t('space.cleanup.archive')}
217 </Button>
218 ) : null}
219 {OVERWRITE_KINDS.has(item.kind) && item.overwrite_count > 0 ? (
220 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={clearOverwrites}>
221 {t('space.cleanup.clearOverwrites')}
222 </Button>
223 ) : null}
224 {DELETE_KINDS.has(item.kind) ? (
225 <Button
226 variant="ghost"
227 className="settings-danger-btn h-8 text-[12.5px]"
228 disabled={busy || hasChildren}
229 title={hasChildren ? t('space.cleanup.deleteBlocked') : undefined}
230 onClick={remove}
231 >
232 {t('common.action.delete')}
233 </Button>
234 ) : null}
235 </>
236 }
237 />
238 </div>
239 )
240}
No newline at end of file
Modified web/src/features/space-management/ui/space-header.tsx View web/src/features/space-management/ui/space-header.tsx
@@ -1,6 +1,7 @@
11import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
22import { Link } from 'react-router-dom'
33import {
4 Bot,
45 ChevronDown,
56 Download,
67 FolderGit2,
@@ -14,6 +15,7 @@
1415} from 'lucide-react'
1516import type { LucideIcon } from 'lucide-react'
1617import {
18 createAgentCollection,
1719 createBoard,
1820 createInvite,
1921 createRepo,
@@ -36,6 +38,7 @@
3638 | 'repo'
3739 | 'import'
3840 | 'board'
41 | 'agent'
3942 | 'room'
4043 | 'dir'
4144 | 'privacy'
@@ -80,6 +83,9 @@
8083 // /me/spaces ships `owner_uid`; the full SpaceDto embeds `owner`.
8184 const ownerUid = space.owner?.user_id ?? (space as unknown as { owner_uid?: string }).owner_uid
8285 const isOwner = Boolean(ownerUid && viewerId && ownerUid === viewerId)
86 // 逐项对齐服务端鉴权:仓库/导入永远 owner-only;看板/智能体/文件夹/聊天室都在
87 // `manage_channels` 一档(owner 天然具备全部权限)。
88 const canManageChannels = isOwner || perms.has('manage_channels')
8389 // The image endpoint already enforces visibility for private communities and
8490 // provides a presigned URL, so the sidebar can use the same uploaded banner
8591 // as the community settings and directory views.
@@ -122,6 +128,7 @@
122128 dialog !== 'repo' &&
123129 dialog !== 'import' &&
124130 dialog !== 'board' &&
131 dialog !== 'agent' &&
125132 dialog !== 'room'
126133 )
127134 return
@@ -228,13 +235,24 @@
228235 {perms.has('manage_invites') ? (
229236 <MenuItem icon={UserPlus} label={t('space.menu.invite')} onClick={() => openDialog('invite')} />
230237 ) : null}
231 <MenuItem icon={FolderGit2} label={t('space.menu.createRepo')} onClick={() => openDialog('repo')} />
232 <MenuItem icon={Download} label={t('space.import')} onClick={() => openDialog('import')} />
233 <MenuItem icon={SquareKanban} label={t('space.menu.createBoard')} onClick={() => openDialog('board')} />
234 {perms.has('manage_channels') ? (
238 {isOwner ? (
239 <MenuItem icon={FolderGit2} label={t('space.menu.createRepo')} onClick={() => openDialog('repo')} />
240 ) : null}
241 {isOwner ? (
242 <MenuItem icon={Download} label={t('space.import')} onClick={() => openDialog('import')} />
243 ) : null}
244 {canManageChannels ? (
245 <MenuItem icon={SquareKanban} label={t('space.menu.createBoard')} onClick={() => openDialog('board')} />
246 ) : null}
247 {canManageChannels ? (
248 <MenuItem icon={Bot} label={t('space.menu.createAgent')} onClick={() => openDialog('agent')} />
249 ) : null}
250 {canManageChannels ? (
235251 <MenuItem icon={Hash} label={t('space.menu.createRoom')} onClick={() => openDialog('room')} />
236252 ) : null}
237 <MenuItem icon={FolderPlus} label={t('space.menu.createDir')} onClick={() => openDialog('dir')} />
253 {canManageChannels ? (
254 <MenuItem icon={FolderPlus} label={t('space.menu.createDir')} onClick={() => openDialog('dir')} />
255 ) : null}
238256 <MenuDivider />
239257 {canManage ? (
240258 <MenuItem icon={Settings} label={t('space.menu.settings')} onClick={onOpenSettings} />
@@ -415,6 +433,47 @@
415433 onClose={closeDialog}
416434 />
417435 <FormModal
436 title={t('space.agent.create.title')}
437 description={t('space.agent.create.description')}
438 open={dialog === 'agent'}
439 confirmLabel={t('common.action.create')}
440 fields={[
441 { kind: 'text', key: 'name', label: t('space.agent.nameField'), placeholder: 'research-team' },
442 {
443 kind: 'textarea',
444 key: 'description',
445 label: t('space.agent.description'),
446 optional: true,
447 maxLength: 512,
448 },
449 {
450 kind: 'text',
451 key: 'icon',
452 label: t('space.agent.icon'),
453 placeholder: '🤖',
454 optional: true,
455 maxLength: 16,
456 },
457 {
458 kind: 'select',
459 key: 'parent_dir_id',
460 label: t('space.repo.dir'),
461 initialValue: '',
462 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
463 },
464 ]}
465 onSubmit={async values => {
466 await createAgentCollection(space.name, {
467 name: String(values.name),
468 description: String(values.description).trim() || null,
469 icon: String(values.icon).trim() || null,
470 parent_dir_id: String(values.parent_dir_id) || null,
471 })
472 onListsChanged()
473 }}
474 onClose={closeDialog}
475 />
476 <FormModal
418477 title={t('space.room.title')}
419478 open={dialog === 'room'} confirmLabel={t('common.action.create')}
420479 fields={[
Modified web/src/features/space-management/ui/space-settings-modal.tsx View web/src/features/space-management/ui/space-settings-modal.tsx
@@ -1,5 +1,5 @@
11import { useEffect, useState } from 'react'
2import { Ban, ClipboardList, Image, KeyRound, Settings, Shield, Trash2, UserPlus, UserRound, Users } from 'lucide-react'
2import { Ban, ClipboardList, Eraser, Image, KeyRound, Settings, Shield, Trash2, UserPlus, UserRound, Users } from 'lucide-react'
33import { Alert, Busy, SettingsModalShell, type SettingsSection } from '@/shared/ui'
44import { spaceAdminKey, useAsync } from '@/shared/lib'
55import { useT } from '@/shared/i18n'
@@ -8,6 +8,7 @@
88import type { MySpace } from '../model/api'
99import { BansSection } from './space-settings/bans-section'
1010import { ChannelPermissionsSection } from './space-settings/channel-permissions-section'
11import { CleanupSection } from './space-settings/cleanup-section'
1112import { DangerSection } from './space-settings/danger-section'
1213import { GeneralSection } from './space-settings/general-section'
1314import { ImagesSection } from './space-settings/images-section'
@@ -97,6 +98,8 @@
9798 ...(perms.has(PERM.channels) || perms.has(PERM.overwrites)
9899 ? [{ id: 'channels', label: t('space.channelPermissions.title'), icon: KeyRound, group: membersGroup }]
99100 : []),
101 // 对象清理是危险区里的一员,但比删空间频繁得多:单列一项,且仅所有者可见。
102 ...(isOwner ? [{ id: 'cleanup', label: t('space.settings.sections.cleanup'), icon: Eraser, group: membersGroup }] : []),
100103 { id: 'danger', label: t('space.settings.danger'), icon: Trash2 },
101104 ]
102105
@@ -185,6 +188,7 @@
185188 {section === 'join' ? <JoinRulesSection {...props} /> : null}
186189 {section === 'bans' ? <BansSection {...props} /> : null}
187190 {section === 'channels' ? <ChannelPermissionsSection {...props} /> : null}
191 {section === 'cleanup' ? <CleanupSection {...props} /> : null}
188192 {section === 'danger' ? <DangerSection {...props} onDeleted={onDeleted} /> : null}
189193 </SettingsModalShell>
190194 )
Modified web/src/features/space-management/index.ts View web/src/features/space-management/index.ts
@@ -7,4 +7,4 @@
77 SpaceSettingsModal,
88 type SpaceSettingsModalProps,
99} from './ui/space-settings-modal'
10export { fetchSpaceDir, moveRoom, moveSpaceDir, type DirNode } from './model/api'
10export { acceptSpaceInvite, fetchSpaceDir, moveRoom, moveSpaceDir, type DirNode, type MySpace } from './model/api'
Added web/src/pages/space-invite/ui/space-invite-page.tsx View web/src/pages/space-invite/ui/space-invite-page.tsx
@@ -0,0 +1,158 @@
1import { useEffect, useRef, useState } from 'react'
2import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
3import { ArrowRight, Loader2, MailQuestionMark, TriangleAlert } from 'lucide-react'
4import { useT } from '@/shared/i18n'
5import { Alert, Button } from '@/shared/ui'
6import { ApiFailure, meKey, useAsync } from '@/shared/lib'
7import { AuthCard, AuthShell, IconBadge, fetchSessionUser } from '@/features/auth'
8import { acceptSpaceInvite } from '@/features/space-management'
9
10type Phase = 'joining' | 'signin' | 'failed' | 'invalid'
11type Failure = { code?: number; message?: string }
12
13/** Codes that mean the link itself is spent — retrying cannot change the answer. */
14const SPENT = new Set([403, 404, 409])
15
16/**
17 * Capability link from the invite email:
18 * `{web_base_url}/spaces/invites/accept?code=…&space=…`.
19 *
20 * A signed-in visitor accepts straight away and lands inside the space they
21 * just joined (the `space` hint); an anonymous one is asked to sign in first —
22 * the code stays in the URL, so the same link keeps working after logging in.
23 */
24export function SpaceInvitePage() {
25 const { t } = useT()
26 const navigate = useNavigate()
27 const location = useLocation()
28 const [params] = useSearchParams()
29 const code = params.get('code') ?? ''
30 const space = params.get('space') ?? ''
31 const [tick, setTick] = useState(0)
32 const me = useAsync(fetchSessionUser, [tick], { cacheKey: meKey() })
33 const [acceptFailure, setAcceptFailure] = useState<Failure | null>(null)
34 const started = useRef(-1)
35
36 // A refused session (401/403) outranks a cached identity: the visitor has to
37 // sign in as the account the invitation was addressed to.
38 const refused = me.error instanceof ApiFailure && (me.error.code === 401 || me.error.code === 403)
39 const probeFailure = !me.loading && !me.data && !refused && Boolean(me.error)
40
41 useEffect(() => {
42 // `started` keeps the accept single-shot per attempt: the effect re-runs as
43 // caches refresh, and a consumed code must never be posted twice — only the
44 // retry button bumps `tick` and unlocks another post.
45 if (!code || me.loading || !me.data || started.current === tick) return
46 started.current = tick
47 acceptSpaceInvite(code).then(
48 () => navigate(space ? `/app/${encodeURIComponent(space)}` : '/app', { replace: true }),
49 (err: unknown) => {
50 setAcceptFailure(
51 err instanceof ApiFailure
52 ? { code: err.code, message: err.message }
53 : { message: err instanceof Error ? err.message : undefined },
54 )
55 },
56 )
57 }, [tick, code, space, me.loading, me.data, navigate])
58
59 const failure: Failure | null =
60 acceptFailure ?? (probeFailure ? { message: (me.error as Error | undefined)?.message } : null)
61 const phase: Phase = !code
62 ? 'invalid'
63 : failure
64 ? 'failed'
65 : me.data
66 ? 'joining'
67 : me.loading
68 ? 'joining'
69 : 'signin'
70
71 const failureText =
72 failure?.code === 404
73 ? t('space.invite.accept.notFound')
74 : failure?.code === 403
75 ? t('space.invite.accept.mismatch')
76 : failure?.code === 409
77 ? t('space.invite.accept.used')
78 : failure?.message || t('space.invite.accept.failed')
79
80 const badge =
81 phase === 'signin' ? (
82 <IconBadge><MailQuestionMark size={20} aria-hidden /></IconBadge>
83 ) : phase === 'joining' ? (
84 <IconBadge><Loader2 size={20} className="animate-spin" aria-hidden /></IconBadge>
85 ) : (
86 <IconBadge><TriangleAlert size={20} aria-hidden /></IconBadge>
87 )
88
89 return (
90 <AuthShell
91 current={t('space.invite.accept.crumb').toUpperCase()}
92 hint={t('space.invite.accept.hint').toUpperCase()}
93 >
94 <AuthCard
95 badge={badge}
96 heading={space ? t('space.invite.accept.headingSpace', { space }) : t('space.invite.accept.heading')}
97 subheading={t('space.invite.accept.sub')}
98 >
99 {phase === 'joining' ? (
100 <p className="flex items-center justify-center gap-2 text-[13px] text-muted-foreground" role="status">
101 <Loader2 size={15} className="animate-spin" aria-hidden />
102 {t('space.invite.accept.joining')}
103 </p>
104 ) : null}
105
106 {phase === 'signin' ? (
107 <div className="flex w-full flex-col gap-3">
108 <Alert tone="info">{t('space.invite.accept.signInNote')}</Alert>
109 <Button
110 size="lg"
111 className="w-full"
112 onClick={() =>
113 navigate(`/auth/login?redirect=${encodeURIComponent(location.pathname + location.search)}`)
114 }
115 >
116 {t('space.invite.accept.login')}
117 <ArrowRight size={15} aria-hidden />
118 </Button>
119 <Button variant="secondary" size="lg" className="w-full" onClick={() => navigate('/auth/signup')}>
120 {t('space.invite.accept.signup')}
121 </Button>
122 </div>
123 ) : null}
124
125 {phase === 'failed' ? (
126 <div className="flex w-full flex-col gap-3">
127 <Alert tone="error">{failureText}</Alert>
128 <div className="flex w-full gap-2">
129 {SPENT.has(failure?.code ?? 0) ? null : (
130 <Button
131 className="flex-1"
132 onClick={() => {
133 setAcceptFailure(null)
134 setTick(value => value + 1)
135 }}
136 >
137 {t('space.invite.accept.retry')}
138 </Button>
139 )}
140 <Button variant="secondary" className="flex-1" onClick={() => navigate('/app')}>
141 {t('space.invite.accept.home')}
142 </Button>
143 </div>
144 </div>
145 ) : null}
146
147 {phase === 'invalid' ? (
148 <div className="flex w-full flex-col gap-3">
149 <Alert tone="error">{t('space.invite.accept.invalid')}</Alert>
150 <Button variant="secondary" size="lg" className="w-full" onClick={() => navigate('/app')}>
151 {t('space.invite.accept.home')}
152 </Button>
153 </div>
154 ) : null}
155 </AuthCard>
156 </AuthShell>
157 )
158}
No newline at end of file
Added web/src/pages/space-invite/index.ts View web/src/pages/space-invite/index.ts
@@ -0,0 +1 @@
1export { SpaceInvitePage } from './ui/space-invite-page'
No newline at end of file
Modified web/src/shared/i18n/messages/en/space.ts View web/src/shared/i18n/messages/en/space.ts
@@ -150,6 +150,7 @@
150150 'space.menu.invite': 'Invite friends',
151151 'space.menu.createRepo': 'Create repository',
152152 'space.menu.createBoard': 'Create issue board',
153 'space.menu.createAgent': 'Create agent set',
153154 'space.menu.createRoom': 'Create channel',
154155 'space.menu.createDir': 'Create folder',
155156 'space.menu.settings': 'Space settings',
@@ -169,6 +170,22 @@
169170 'space.invite.groupsHint': 'Pick at least one role',
170171 'space.invite.missingTarget': 'Provide an email or a username.',
171172 'space.invite.sent': 'Invite sent: {target} can join after accepting.',
173 'space.invite.accept.crumb': 'INVITE',
174 'space.invite.accept.hint': 'PRIVATE LINK FROM AN INVITE EMAIL',
175 'space.invite.accept.heading': 'Accept invitation',
176 'space.invite.accept.headingSpace': 'Join {space}',
177 'space.invite.accept.sub': 'Accepting makes you a member with the roles chosen for this invitation.',
178 'space.invite.accept.joining': 'Joining…',
179 'space.invite.accept.signInNote': 'This invitation was addressed to a specific account: sign in (or sign up) first, then open the link from the email again.',
180 'space.invite.accept.login': 'Sign in',
181 'space.invite.accept.signup': 'Create an account',
182 'space.invite.accept.invalid': 'The link is missing its invite code — reopen it from the email.',
183 'space.invite.accept.notFound': 'This invitation does not exist or was revoked.',
184 'space.invite.accept.mismatch': 'The signed-in account is not the invitee. Sign in as the invited address and try again.',
185 'space.invite.accept.used': 'This invitation was already used or has expired.',
186 'space.invite.accept.failed': 'Could not join. Try again in a moment.',
187 'space.invite.accept.retry': 'Retry',
188 'space.invite.accept.home': 'Back to home',
172189 'space.repo.title': 'Create repository',
173190 'space.repo.name': 'Repository name',
174191 'space.repo.nameHint': 'Lowercase letters, digits and hyphens',
@@ -184,6 +201,11 @@
184201 'space.board.description': 'Description · Optional',
185202 'space.board.icon': 'Icon · Optional',
186203 'space.board.dir': 'Parent folder',
204 'space.agent.create.title': 'Create agent set',
205 'space.agent.create.description': 'An agent set is a directory object; every agent inside it owns a long-lived runtime session.',
206 'space.agent.nameField': 'Name',
207 'space.agent.description': 'Description · optional',
208 'space.agent.icon': 'Icon · optional',
187209 'space.room.title': 'Create channel',
188210 'space.room.name': 'Channel name',
189211 'space.room.nameHint': 'Lowercase letters, digits and hyphens',
@@ -316,6 +338,38 @@
316338 'space.settings.sections.join': 'Join rules',
317339 'space.settings.sections.joinRequests': 'Join requests',
318340 'space.settings.sections.bans': 'Bans',
341 'space.settings.sections.cleanup': 'Object cleanup',
342 'space.cleanup.ownerSub': 'Bulk-clean directories and objects in the space. Space owners only.',
343 'space.cleanup.notOwner': 'Object cleanup is available to the space owner only.',
344 'space.cleanup.sub': 'Delete or adjust visibility quickly: repositories can toggle public/private and be archived, channels can toggle public/private or be deleted, boards and agent collections can be deleted, empty directories can be deleted, and nodes with visibility overwrites can be reset to the default.',
345 'space.cleanup.filterPlaceholder': 'Filter by name / path / type',
346 'space.cleanup.empty': 'No matching objects.',
347 'space.cleanup.children': '{count} child node(s)',
348 'space.cleanup.overwrites': '{count} visibility overwrite(s)',
349 'space.cleanup.archived': 'Archived',
350 'space.cleanup.makePrivate': 'Make private',
351 'space.cleanup.makePublic': 'Make public',
352 'space.cleanup.archive': 'Archive',
353 'space.cleanup.unarchive': 'Unarchive',
354 'space.cleanup.clearOverwrites': 'Clear visibility overwrites',
355 'space.cleanup.deleteBlocked': 'This node still has children; empty it first.',
356 'space.cleanup.confirmDelete': 'Delete "{name}"? This cannot be undone.',
357 'space.cleanup.confirmClearOverwrites': 'Clear every visibility overwrite on "{name}" and restore the default?',
358 'space.cleanup.updated': 'Updated.',
359 'space.cleanup.deleted': 'Deleted.',
360 'space.cleanup.overwritesCleared': 'Visibility overwrites cleared.',
361 'space.cleanup.kind.dir': 'Directory',
362 'space.cleanup.kind.object': 'Object',
363 'space.cleanup.kind.repo': 'Repository',
364 'space.cleanup.kind.board': 'Board',
365 'space.cleanup.kind.collection': 'Agent collection',
366 'space.cleanup.kind.text': 'Text channel',
367 'space.cleanup.kind.voice': 'Voice channel',
368 'space.cleanup.kind.stage': 'Stage channel',
369 'space.cleanup.kind.category': 'Category',
370 'space.cleanup.kind.news': 'News channel',
371 'space.cleanup.kind.guildRoom': 'Channel',
372 'space.cleanup.kind.thread': 'Thread',
319373 'space.settings.fullLoadFailed': 'Could not load the full space record; these forms fall back to the cached list row and some fields may be stale.',
320374 'space.general.title': 'Space profile',
321375 'space.general.sub': 'Name, description and how people join.',
Modified web/src/shared/i18n/messages/zh-CN/space.ts View web/src/shared/i18n/messages/zh-CN/space.ts
@@ -150,6 +150,7 @@
150150 'space.menu.invite': '邀请朋友',
151151 'space.menu.createRepo': '创建仓库',
152152 'space.menu.createBoard': '创建问题集',
153 'space.menu.createAgent': '创建智能体集',
153154 'space.menu.createRoom': '创建聊天室',
154155 'space.menu.createDir': '创建文件夹',
155156 'space.menu.settings': '社区设置',
@@ -169,6 +170,22 @@
169170 'space.invite.groupsHint': '至少选择一个角色',
170171 'space.invite.missingTarget': '请填写邮箱或用户名,至少一个。',
171172 'space.invite.sent': '邀请已发出:{target} 接受后即可加入。',
173 'space.invite.accept.crumb': '社区邀请',
174 'space.invite.accept.hint': '来自邀请邮件的专属链接',
175 'space.invite.accept.heading': '接受邀请',
176 'space.invite.accept.headingSpace': '加入 {space}',
177 'space.invite.accept.sub': '接受后即可成为成员,并获得邀请时选定的角色。',
178 'space.invite.accept.joining': '正在加入…',
179 'space.invite.accept.signInNote': '这个邀请是发给某个账号的:请先登录(或注册)后再打开邮件里的链接。',
180 'space.invite.accept.login': '去登录',
181 'space.invite.accept.signup': '注册新账号',
182 'space.invite.accept.invalid': '链接里缺少邀请码,请回到邮件重新打开。',
183 'space.invite.accept.notFound': '邀请不存在,或已被撤销。',
184 'space.invite.accept.mismatch': '当前登录账号不是被邀请的账号,请换用收到邀请的邮箱登录后重试。',
185 'space.invite.accept.used': '邀请已被使用或已过期。',
186 'space.invite.accept.failed': '加入失败,请稍后再试。',
187 'space.invite.accept.retry': '重试',
188 'space.invite.accept.home': '返回首页',
172189 'space.repo.title': '创建仓库',
173190 'space.repo.name': '仓库名称',
174191 'space.repo.nameHint': '小写字母、数字与连字符',
@@ -184,6 +201,11 @@
184201 'space.board.description': '描述 · 可选',
185202 'space.board.icon': '图标 · 可选',
186203 'space.board.dir': '所在文件夹',
204 'space.agent.create.title': '创建智能体集',
205 'space.agent.create.description': '智能体集是空间目录中的一类对象,集内的每个智能体都拥有自己的运行时长期会话。',
206 'space.agent.nameField': '名称',
207 'space.agent.description': '描述 · 可选',
208 'space.agent.icon': '图标 · 可选',
187209 'space.room.title': '创建聊天室',
188210 'space.room.name': '频道名称',
189211 'space.room.nameHint': '小写字母、数字与连字符',
@@ -316,6 +338,38 @@
316338 'space.settings.sections.join': '加入规则',
317339 'space.settings.sections.joinRequests': '加入申请',
318340 'space.settings.sections.bans': '封禁',
341 'space.settings.sections.cleanup': '对象清理',
342 'space.cleanup.ownerSub': '批量清理空间里的目录与对象,仅空间所有者可访问。',
343 'space.cleanup.notOwner': '对象清理只对空间所有者开放。',
344 'space.cleanup.sub': '快速删除或调整可见性:仓库可切换公开/私有并归档,频道可切换公开/私有或删除,问题集 / 智能体集可直接删除,空目录可删除,带可见性覆盖的节点可一键恢复默认。',
345 'space.cleanup.filterPlaceholder': '按名称 / 路径 / 类型过滤',
346 'space.cleanup.empty': '没有匹配的对象。',
347 'space.cleanup.children': '子节点 {count}',
348 'space.cleanup.overwrites': '可见性覆盖 {count}',
349 'space.cleanup.archived': '已归档',
350 'space.cleanup.makePrivate': '设为私有',
351 'space.cleanup.makePublic': '设为公开',
352 'space.cleanup.archive': '归档',
353 'space.cleanup.unarchive': '取消归档',
354 'space.cleanup.clearOverwrites': '清除可见性覆盖',
355 'space.cleanup.deleteBlocked': '还有子节点,先清空再删除。',
356 'space.cleanup.confirmDelete': '确定删除「{name}」?该操作不可撤销。',
357 'space.cleanup.confirmClearOverwrites': '清除「{name}」的全部可见性覆盖,恢复为默认可见性?',
358 'space.cleanup.updated': '已更新。',
359 'space.cleanup.deleted': '已删除。',
360 'space.cleanup.overwritesCleared': '已清除可见性覆盖。',
361 'space.cleanup.kind.dir': '目录',
362 'space.cleanup.kind.object': '对象',
363 'space.cleanup.kind.repo': '仓库',
364 'space.cleanup.kind.board': '问题集',
365 'space.cleanup.kind.collection': '智能体集',
366 'space.cleanup.kind.text': '文字频道',
367 'space.cleanup.kind.voice': '语音频道',
368 'space.cleanup.kind.stage': '舞台频道',
369 'space.cleanup.kind.category': '分类',
370 'space.cleanup.kind.news': '公告频道',
371 'space.cleanup.kind.guildRoom': '频道',
372 'space.cleanup.kind.thread': '线程',
319373 'space.settings.fullLoadFailed': '读取完整社区资料失败,以下表单基于列表缓存,部分字段可能不是最新。',
320374 'space.general.title': '社区资料',
321375 'space.general.sub': '名称、介绍与加入方式。',