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
Modified lib/export/src/authz.rs lib/export/src/authz.rs +23 −2 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 lib/export/src/lib.rs +1 −0 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 lib/export/src/space_token.rs +102 −0 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 lib/export/Cargo.toml +1 −0 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 lib/git/src/share/access.rs +53 −0 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 lib/mcp-api/src/tools/args.rs +6 −0 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 lib/mcp-api/src/tools/issue.rs +10 −5 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 lib/mcp-api/src/tools/me.rs +1 −0 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 lib/mcp-api/src/tools/repo.rs +4 −0 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 lib/mcp-api/src/auth.rs +26 −0 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 lib/mcp-api/src/lib.rs +1 −0 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 lib/mcp-api/src/server.rs +34 −1 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 lib/mcp-api/src/space_token.rs +248 −0 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 lib/mcp-api/Cargo.toml +2 −0 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 lib/space/src/services/invites.rs +5 −2 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 web/src/features/space-management/model/api.ts +14 −0 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 web/src/features/space-management/model/space-admin.ts +53 −0 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 web/src/features/space-management/ui/space-settings/cleanup-section.tsx +240 −0 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 web/src/features/space-management/ui/space-header.tsx +64 −5 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 web/src/features/space-management/ui/space-settings-modal.tsx +5 −1 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 web/src/features/space-management/index.ts +1 −1 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 web/src/pages/space-invite/ui/space-invite-page.tsx +158 −0 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 web/src/pages/space-invite/index.ts +1 −0 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 web/src/shared/i18n/messages/en/space.ts +54 −0 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 web/src/shared/i18n/messages/zh-CN/space.ts +54 −0 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': '名称、介绍与加入方式。',