Skip to content
Clone

refactor: replace Preact with React imports for improved consistency across components

ZhenYi committed
parent 1098df8 commit 07f791d Browse files

80 files changed

+440 −7339

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 80
Modified lib/auth/src/handlers/identity/login/service.rs View lib/auth/src/handlers/identity/login/service.rs
@@ -396,7 +396,7 @@
396396 .exec(self.db().write())
397397 .await?;
398398
399 crate::handlers::identity::me::service::invalidate_me_cache(self.cache(), user.id).await;
399 crate::me::service::invalidate_me_cache(self.cache(), user.id).await;
400400 self.audit(
401401 session,
402402 user.id,
Modified lib/auth/src/handlers/identity/logout/service.rs View lib/auth/src/handlers/identity/logout/service.rs
@@ -53,7 +53,7 @@
5353 }
5454
5555 if let Some(uid) = user_id {
56 crate::handlers::identity::me::service::invalidate_me_cache(self.cache(), uid).await;
56 crate::me::service::invalidate_me_cache(self.cache(), uid).await;
5757 if let Err(error) = crate::share::audit::entry(
5858 self.db(),
5959 uid,
Modified lib/auth/src/handlers/identity/me/handler.rs View lib/auth/src/handlers/identity/me/handler.rs
@@ -3,7 +3,7 @@
33
44use crate::envelope::{Envelope, NoData};
55use crate::errors::AuthError;
6use crate::handlers::identity::me::types::MeResponse;
6use crate::me::types::MeResponse;
77
88#[utoipa::path(
99 get,
Modified lib/auth/src/handlers/identity/me/mod.rs View lib/auth/src/handlers/identity/me/mod.rs
@@ -1,6 +1 @@
11pub mod handler;
2pub mod service;
3pub mod types;
4
5pub use service::{invalidate_me_cache, me_cache_keys};
6pub use types::MeResponse;
Deleted lib/auth/src/handlers/identity/me/service.rs
@@ -1,75 +0,0 @@
1use std::time::Duration;
2
3use crate::models::users::users;
4use crate::session::Session;
5use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
6
7use crate::AuthSvc;
8use crate::errors::{AuthError, AuthResult};
9use crate::handlers::identity::me::types::MeResponse;
10
11pub const ME_CACHE_TTL_SECS: u64 = 300;
12const ME_COUNT_TTL_SECS: u64 = 600;
13const ME_REFRESH_EVERY: i64 = 5;
14
15pub fn me_cache_keys(user_id: i64) -> (String, String) {
16 (
17 format!("auth:me:{user_id}"),
18 format!("auth:me:count:{user_id}"),
19 )
20}
21
22pub async fn invalidate_me_cache(cache: &crate::cache::Cache, user_id: i64) {
23 let (cache_key, count_key) = me_cache_keys(user_id);
24 let dropped = async {
25 cache.delete(&cache_key).await?;
26 cache.delete(&count_key).await?;
27 Ok::<(), crate::cache::CacheError>(())
28 }
29 .await;
30 if let Err(error) = dropped {
31 tracing::warn!(%error, user_id, "failed to drop the me cache");
32 }
33}
34
35impl AuthSvc {
36 #[tracing::instrument(skip_all, fields(request_id = %_request_id, client_ip = ?session.client_ip().ok().flatten()))]
37 pub async fn me(&self, session: Session, _request_id: String) -> AuthResult<MeResponse> {
38 let user_id = self
39 .require_session(&session, Some(_request_id.as_str()))
40 .await?;
41 self.me_for_user(user_id).await
42 }
43
44 pub(crate) async fn me_for_user(&self, user_id: i64) -> AuthResult<MeResponse> {
45 let (cache_key, count_key) = me_cache_keys(user_id);
46
47 let count = self.cache().incr(&count_key).await?;
48 if count == 1 {
49 let _ = self
50 .cache()
51 .expire(&count_key, Duration::from_secs(ME_COUNT_TTL_SECS))
52 .await;
53 }
54 if count % ME_REFRESH_EVERY != 0
55 && let Some(cached) = self.cache().get_json::<MeResponse>(&cache_key).await?
56 {
57 return Ok(cached);
58 }
59
60 let user = users::Entity::find_by_id(user_id)
61 .filter(users::Column::DeletedAt.is_null())
62 .one(self.db().read())
63 .await?
64 .ok_or(AuthError::Unauthenticated)?;
65
66 let me = MeResponse::from(user);
67
68 let _ = self
69 .cache()
70 .set_json_ex(&cache_key, &me, Duration::from_secs(ME_CACHE_TTL_SECS))
71 .await;
72
73 Ok(me)
74 }
75}
Deleted lib/auth/src/handlers/identity/me/types.rs
@@ -1,33 +0,0 @@
1use crate::models::users::users;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use utoipa::ToSchema;
5
6#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, ToSchema)]
7pub struct MeResponse {
8 pub user_id: i64,
9 pub username: String,
10 pub displayname: Option<String>,
11 pub email: String,
12 pub is_admin: bool,
13 pub avatar_url: Option<String>,
14 pub email_verified_at: Option<DateTime<Utc>>,
15 pub created_at: DateTime<Utc>,
16 pub version: i32,
17}
18
19impl From<users::Model> for MeResponse {
20 fn from(user: users::Model) -> Self {
21 Self {
22 user_id: user.id,
23 username: user.username,
24 displayname: user.displayname,
25 email: user.email,
26 is_admin: user.is_admin,
27 avatar_url: None,
28 email_verified_at: user.email_verified_at,
29 created_at: user.created_at,
30 version: user.version,
31 }
32 }
33}
Deleted lib/auth/src/handlers/identity/organization/error.rs
@@ -1,22 +0,0 @@
1use crate::errors::AuthError;
2
3pub(crate) fn reason_error(reason: &'static str, permission: &str) -> AuthError {
4 match reason {
5 "org_inactive" => AuthError::OrganizationInactive,
6 "member_banned" => AuthError::OrganizationMemberBanned,
7 "not_member" | "member_left" => AuthError::Forbidden,
8 _ => AuthError::ScopeInsufficient {
9 required: permission.to_owned(),
10 },
11 }
12}
13
14pub(crate) fn map_insert_conflict(error: sea_orm::DbErr) -> AuthError {
15 if let sea_orm::DbErr::Query(sea_orm::RuntimeErr::SqlxError(source)) = &error
16 && let Some(code) = source.as_database_error().and_then(|db| db.code())
17 && code.as_ref() == "23505"
18 {
19 return AuthError::OrganizationSlugTaken;
20 }
21 AuthError::from(error)
22}
Modified lib/auth/src/handlers/identity/organization/handler.rs View lib/auth/src/handlers/identity/organization/handler.rs
@@ -3,7 +3,7 @@
33
44use crate::envelope::{Envelope, NoData};
55use crate::errors::AuthError;
6use crate::handlers::identity::organization::types::{
6use crate::organization::types::{
77 AddMemberBody, BanMemberBody, CheckPermissionBody, CreateOrganizationBody, CreateRoleBody,
88 InviteSummary, MemberSummary, OrganizationSummary, PermissionCheck, PermissionGrant,
99 RoleSummary, SetMemberRoleBody, SetRolePermissionsBody, UpdateBanBody, UpdateMemberBody,
Deleted lib/auth/src/handlers/identity/organization/invite.rs
@@ -1,480 +0,0 @@
1use chrono::Utc;
2use sea_orm::entity::Set;
3use sea_orm::sea_query::{Expr, ExprTrait};
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder,
6 QuerySelect, TransactionTrait,
7};
8use uuid::Uuid;
9
10use crate::AuthSvc;
11use crate::errors::{AuthError, AuthResult};
12use crate::events::label;
13use crate::handlers::identity::organization::member;
14use crate::handlers::identity::organization::service::{bump_version, normalize_reason};
15use crate::handlers::identity::organization::types::{
16 DEFAULT_INVITE_TTL_SECS, InviteSummary, SYSTEM_ROLE_MEMBER, SYSTEM_ROLE_OWNER,
17};
18use crate::models::space::{
19 OrgJoinMethod, OrgMemberRole, OrganizationStatus, organization_invite_redemptions,
20 organization_invites, organization_roles,
21};
22use crate::share::crypto;
23
24impl AuthSvc {
25 #[allow(clippy::too_many_arguments)]
26 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
27 pub async fn create_invite(
28 &self,
29 actor_user_id: i64,
30 organization_id: Uuid,
31 role_id: Option<Uuid>,
32 target_user_id: Option<i64>,
33 max_uses: i32,
34 ttl_secs: Option<u64>,
35 request_id: Option<&str>,
36 ) -> AuthResult<(InviteSummary, String)> {
37 self.create_invite_with_extras(
38 actor_user_id,
39 organization_id,
40 role_id,
41 target_user_id,
42 max_uses,
43 ttl_secs,
44 None,
45 &[],
46 request_id,
47 )
48 .await
49 }
50
51 /// 邮箱型邀请与额外角色必须在同一个事务里写入,否则邀请已经可用而扩展字段丢失。
52 #[allow(clippy::too_many_arguments)]
53 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
54 pub async fn create_invite_with_extras(
55 &self,
56 actor_user_id: i64,
57 organization_id: Uuid,
58 role_id: Option<Uuid>,
59 target_user_id: Option<i64>,
60 max_uses: i32,
61 ttl_secs: Option<u64>,
62 email: Option<String>,
63 extra_role_ids: &[Uuid],
64 request_id: Option<&str>,
65 ) -> AuthResult<(InviteSummary, String)> {
66 self.require_permission(actor_user_id, organization_id, "invite.create", request_id)
67 .await?;
68 let organization = self.load_organization(organization_id).await?;
69 if organization.status != OrganizationStatus::Active {
70 return Err(AuthError::OrganizationInactive);
71 }
72 if max_uses < 0 {
73 return Err(AuthError::MalformedRequest);
74 }
75 if let Some(role_id) = role_id {
76 let role = organization_roles::Entity::find_by_id(role_id)
77 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
78 .one(self.db().read())
79 .await?
80 .ok_or(AuthError::OrganizationRoleNotFound)?;
81 if role.key == SYSTEM_ROLE_OWNER {
82 return Err(AuthError::Forbidden);
83 }
84 }
85 if !extra_role_ids.is_empty() {
86 // 额外角色等于提前授予权限,与主动分配角色同等审查。
87 self.require_permission(actor_user_id, organization_id, "role.assign", request_id)
88 .await?;
89 for extra_role_id in extra_role_ids {
90 let role = organization_roles::Entity::find_by_id(*extra_role_id)
91 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
92 .one(self.db().read())
93 .await?
94 .ok_or(AuthError::OrganizationRoleNotFound)?;
95 if role.key == SYSTEM_ROLE_OWNER {
96 return Err(AuthError::Forbidden);
97 }
98 }
99 }
100 let email = email
101 .map(|value| crate::share::validate::validate_email(&value))
102 .transpose()?;
103 let method = match target_user_id {
104 Some(user_id) => {
105 crate::models::users::users::Entity::find_by_id(user_id)
106 .filter(crate::models::users::users::Column::DeletedAt.is_null())
107 .one(self.db().read())
108 .await?
109 .ok_or(AuthError::UserNotFound)?;
110 OrgJoinMethod::InviteTargeted
111 }
112 None => OrgJoinMethod::InviteLink,
113 };
114 let method = if email.is_some() {
115 OrgJoinMethod::InviteEmail
116 } else {
117 method
118 };
119 let extra_role_ids = if extra_role_ids.is_empty() {
120 None
121 } else {
122 Some(
123 extra_role_ids
124 .iter()
125 .map(|id| id.to_string())
126 .collect::<Vec<_>>()
127 .join(","),
128 )
129 };
130
131 // 明文令牌只在这里出现一次,库里只留 sha256。
132 let token = crypto::random_b64url(32);
133 let token_hash = crypto::sha256_hex(token.as_bytes());
134 let ttl = ttl_secs
135 .unwrap_or(DEFAULT_INVITE_TTL_SECS)
136 .clamp(60, 90 * 24 * 60 * 60);
137 let expires_at = Some(Utc::now() + chrono::Duration::seconds(ttl as i64));
138
139 let txn = self.db().write().begin().await?;
140 let row = organization_invites::ActiveModel {
141 organization_id: Set(organization_id),
142 method: Set(method),
143 token_hash: Set(token_hash),
144 role_id: Set(role_id),
145 target_user_id: Set(target_user_id),
146 created_by: Set(actor_user_id),
147 max_uses: Set(max_uses),
148 uses: Set(0),
149 email: Set(email),
150 extra_role_ids: Set(extra_role_ids),
151 expires_at: Set(expires_at),
152 ..Default::default()
153 }
154 .insert(&txn)
155 .await?;
156 bump_version(&txn, organization_id).await?;
157 self.emit_invite(
158 &txn,
159 organization_id,
160 actor_user_id,
161 serde_json::json!({
162 "id": row.id,
163 "method": match row.method {
164 OrgJoinMethod::InviteLink => "invite_link",
165 OrgJoinMethod::InviteTargeted => "invite_targeted",
166 OrgJoinMethod::InviteEmail => "invite_email",
167 OrgJoinMethod::Direct => "direct",
168 },
169 "target_user_id": target_user_id,
170 "max_uses": max_uses,
171 "request_id": request_id,
172 }),
173 request_id,
174 )
175 .await?;
176 txn.commit().await?;
177
178 self.invalidate_org(organization_id).await?;
179 let summary = invite_summary(&row);
180 Ok((summary, token))
181 }
182
183 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
184 pub async fn list_invites(
185 &self,
186 actor_user_id: i64,
187 organization_id: Uuid,
188 include_revoked: bool,
189 limit: u64,
190 offset: u64,
191 request_id: Option<&str>,
192 ) -> AuthResult<Vec<InviteSummary>> {
193 self.require_permission(actor_user_id, organization_id, "invite.view", request_id)
194 .await?;
195 let mut query = organization_invites::Entity::find()
196 .filter(organization_invites::Column::OrganizationId.eq(organization_id));
197 if !include_revoked {
198 query = query.filter(organization_invites::Column::RevokedAt.is_null());
199 }
200 let rows = query
201 .order_by_desc(organization_invites::Column::CreatedAt)
202 .limit(limit)
203 .offset(offset)
204 .all(self.db().read())
205 .await?;
206 Ok(rows.iter().map(invite_summary).collect())
207 }
208
209 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, invite_id = %invite_id, request_id = %request_id.unwrap_or_default()))]
210 pub async fn revoke_invite(
211 &self,
212 actor_user_id: i64,
213 organization_id: Uuid,
214 invite_id: Uuid,
215 reason: Option<&str>,
216 request_id: Option<&str>,
217 ) -> AuthResult<bool> {
218 let reason = normalize_reason(reason)?;
219 let invite = organization_invites::Entity::find_by_id(invite_id)
220 .filter(organization_invites::Column::OrganizationId.eq(organization_id))
221 .one(self.db().read())
222 .await?
223 .ok_or(AuthError::OrganizationInviteNotFound)?;
224 if invite.target_user_id != Some(actor_user_id) {
225 self.require_permission(actor_user_id, organization_id, "invite.revoke", request_id)
226 .await?;
227 }
228 if invite.revoked_at.is_some() {
229 return Ok(false);
230 }
231
232 let txn = self.db().write().begin().await?;
233 organization_invites::Entity::update_many()
234 .col_expr(
235 organization_invites::Column::RevokedAt,
236 Expr::value(Utc::now()),
237 )
238 .filter(organization_invites::Column::Id.eq(invite_id))
239 .exec(&txn)
240 .await?;
241 let version = bump_version(&txn, organization_id).await?;
242 self.emit_invite(
243 &txn,
244 organization_id,
245 actor_user_id,
246 serde_json::json!({
247 "id": invite_id,
248 "action": "revoke",
249 "reason": &reason,
250 "version": version,
251 "request_id": request_id,
252 }),
253 request_id,
254 )
255 .await?;
256 txn.commit().await?;
257
258 self.invalidate_org(organization_id).await?;
259 Ok(true)
260 }
261
262 /// 兑换邀请。整个过程在一个事务里完成,uses 递增用条件更新防止并发超发:
263 /// `UPDATE ... WHERE uses < max_uses` 影响 0 行即视为已用尽。
264 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, request_id = %request_id.unwrap_or_default()))]
265 pub async fn redeem_invite(
266 &self,
267 actor_user_id: i64,
268 token: &str,
269 request_id: Option<&str>,
270 ) -> AuthResult<(Uuid, Uuid, bool)> {
271 let trimmed = token.trim();
272 if trimmed.is_empty() || trimmed.len() > 128 {
273 return Err(AuthError::OrganizationInviteNotFound);
274 }
275 let token_hash = crypto::sha256_hex(trimmed.as_bytes());
276 let invite = organization_invites::Entity::find()
277 .filter(organization_invites::Column::TokenHash.eq(token_hash))
278 .one(self.db().read())
279 .await?
280 .ok_or(AuthError::OrganizationInviteNotFound)?;
281 if invite.revoked_at.is_some() {
282 return Err(AuthError::OrganizationInviteExhausted);
283 }
284 if let Some(expires_at) = invite.expires_at
285 && expires_at <= Utc::now()
286 {
287 return Err(AuthError::OrganizationInviteExpired);
288 }
289 if let Some(target_user_id) = invite.target_user_id
290 && target_user_id != actor_user_id
291 {
292 return Err(AuthError::Forbidden);
293 }
294 let organization = self.load_organization(invite.organization_id).await?;
295 if organization.status != OrganizationStatus::Active {
296 return Err(AuthError::OrganizationInactive);
297 }
298 let actor = crate::models::users::users::Entity::find_by_id(actor_user_id)
299 .filter(crate::models::users::users::Column::DeletedAt.is_null())
300 .one(self.db().read())
301 .await?
302 .ok_or(AuthError::UserNotFound)?;
303 if let Some(email) = invite
304 .email
305 .as_deref()
306 .map(str::trim)
307 .filter(|value| !value.is_empty())
308 && !actor.email.eq_ignore_ascii_case(email)
309 {
310 return Err(AuthError::Forbidden);
311 }
312
313 let txn = self.db().write().begin().await?;
314 let mut consume = organization_invites::Entity::update_many()
315 .col_expr(
316 organization_invites::Column::Uses,
317 Expr::col(organization_invites::Column::Uses).add(1),
318 )
319 .filter(organization_invites::Column::Id.eq(invite.id));
320 if invite.max_uses > 0 {
321 consume = consume.filter(organization_invites::Column::Uses.lt(invite.max_uses));
322 }
323 let consumed = consume.exec(&txn).await?;
324 if consumed.rows_affected == 0 {
325 return Err(AuthError::OrganizationInviteExhausted);
326 }
327
328 let role = match invite.role_id {
329 Some(role_id) => {
330 organization_roles::Entity::find_by_id(role_id)
331 .filter(organization_roles::Column::OrganizationId.eq(invite.organization_id))
332 .one(&txn)
333 .await?
334 }
335 None => self.default_role_row(&txn, invite.organization_id).await?,
336 };
337
338 let method = match invite.method {
339 OrgJoinMethod::InviteTargeted => OrgJoinMethod::InviteTargeted,
340 OrgJoinMethod::InviteEmail => OrgJoinMethod::InviteEmail,
341 _ => OrgJoinMethod::InviteLink,
342 };
343 let already = member::find_any(&txn, invite.organization_id, actor_user_id).await?;
344 let (row, joined) = member::upsert_join(
345 &txn,
346 invite.organization_id,
347 actor_user_id,
348 OrgMemberRole::Member,
349 method,
350 Some(invite.created_by),
351 Some(invite.id),
352 )
353 .await?;
354 if let Some(role) = role
355 && role.key != SYSTEM_ROLE_OWNER
356 {
357 crate::handlers::identity::organization::role::bind_role(
358 &txn,
359 row.id,
360 role.id,
361 invite.created_by,
362 )
363 .await?;
364 }
365 if let Some(extra) = invite.extra_role_ids.as_deref() {
366 for raw in extra.split(',') {
367 let Ok(role_id) = Uuid::parse_str(raw.trim()) else {
368 continue;
369 };
370 let Some(role) = organization_roles::Entity::find_by_id(role_id)
371 .filter(organization_roles::Column::OrganizationId.eq(invite.organization_id))
372 .one(&txn)
373 .await?
374 else {
375 continue;
376 };
377 if role.key == SYSTEM_ROLE_OWNER || role.key == SYSTEM_ROLE_MEMBER {
378 continue;
379 }
380 crate::handlers::identity::organization::role::bind_role(
381 &txn,
382 row.id,
383 role.id,
384 invite.created_by,
385 )
386 .await?;
387 }
388 }
389 let redeemed = already.is_none();
390 if redeemed {
391 let existing = organization_invite_redemptions::Entity::find()
392 .filter(organization_invite_redemptions::Column::InviteId.eq(invite.id))
393 .filter(organization_invite_redemptions::Column::UserId.eq(actor_user_id))
394 .one(&txn)
395 .await?;
396 if existing.is_none() {
397 organization_invite_redemptions::ActiveModel {
398 invite_id: Set(invite.id),
399 user_id: Set(actor_user_id),
400 organization_id: Set(invite.organization_id),
401 ..Default::default()
402 }
403 .insert(&txn)
404 .await?;
405 }
406 }
407 let version = bump_version(&txn, invite.organization_id).await?;
408 self.emit_invite(
409 &txn,
410 invite.organization_id,
411 actor_user_id,
412 serde_json::json!({
413 "id": invite.id,
414 "action": "redeem",
415 "user_id": actor_user_id,
416 "joined": joined,
417 "version": version,
418 "request_id": request_id,
419 }),
420 request_id,
421 )
422 .await?;
423 txn.commit().await?;
424
425 self.invalidate_org(invite.organization_id).await?;
426 Ok((invite.organization_id, row.id, joined))
427 }
428
429 pub(crate) async fn default_role_row<C: ConnectionTrait>(
430 &self,
431 db: &C,
432 organization_id: Uuid,
433 ) -> AuthResult<Option<organization_roles::Model>> {
434 Ok(organization_roles::Entity::find()
435 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
436 .filter(organization_roles::Column::DefaultRole.eq(true))
437 .order_by_asc(organization_roles::Column::Rank)
438 .one(db)
439 .await?)
440 }
441
442 /// 邀请事件与业务写入同事务入队:提交时一起可见,回滚时一起消失。
443 pub(crate) async fn emit_invite(
444 &self,
445 txn: &sea_orm::DatabaseTransaction,
446 organization_id: Uuid,
447 actor_user_id: i64,
448 payload: serde_json::Value,
449 _request_id: Option<&str>,
450 ) -> AuthResult<()> {
451 let labels = crate::events::Labels::new()
452 .with(label::ORGANIZATION_ID, organization_id)
453 .with(label::ACTOR_USER_ID, actor_user_id);
454 self.emit_tx(
455 txn,
456 label::ORGANIZATION_INVITE_CREATE,
457 labels,
458 payload,
459 None,
460 )
461 .await
462 }
463}
464
465pub(crate) fn invite_summary(row: &organization_invites::Model) -> InviteSummary {
466 InviteSummary {
467 id: row.id,
468 organization_id: row.organization_id,
469 method: row.method,
470 role_id: row.role_id,
471 target_user_id: row.target_user_id,
472 created_by: row.created_by,
473 max_uses: row.max_uses,
474 uses: row.uses,
475 expires_at: row.expires_at,
476 revoked_at: row.revoked_at,
477 created_at: row.created_at,
478 email: row.email.clone(),
479 }
480}
Deleted lib/auth/src/handlers/identity/organization/join.rs
@@ -1,486 +0,0 @@
1use chrono::Utc;
2use sea_orm::sea_query::Expr;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set, TransactionTrait,
6};
7use uuid::Uuid;
8
9use crate::handlers::identity::organization::types::{
10 AddMemberBody, JoinRequestSummary, JoinRuleSummary, SYSTEM_ROLE_MEMBER, SYSTEM_ROLE_OWNER,
11};
12use crate::handlers::identity::organization::{member, service};
13use crate::models::space::{
14 OrgJoinMethod, OrgMemberRole, OrganizationJoinRequestStatus, organization_join_requests,
15 organization_join_rules, organization_members, organization_roles,
16};
17use crate::models::users::users;
18use crate::{AuthError, AuthResult, AuthSvc};
19
20fn rule_summary(row: &organization_join_rules::Model) -> JoinRuleSummary {
21 JoinRuleSummary {
22 id: row.id,
23 organization_id: row.organization_id,
24 role_id: row.role_id,
25 trigger: row.trigger.clone(),
26 matcher: row.matcher.clone(),
27 enabled: row.enabled,
28 created_at: row.created_at,
29 updated_at: row.updated_at,
30 }
31}
32
33impl AuthSvc {
34 async fn join_request_summary(
35 &self,
36 row: &organization_join_requests::Model,
37 ) -> AuthResult<JoinRequestSummary> {
38 let user = users::Entity::find_by_id(row.user_id)
39 .one(self.db().read())
40 .await?;
41 Ok(JoinRequestSummary {
42 id: row.id,
43 organization_id: row.organization_id,
44 user_id: row.user_id,
45 username: user
46 .as_ref()
47 .map(|value| value.username.clone())
48 .unwrap_or_default(),
49 displayname: user.and_then(|value| value.displayname),
50 status: row.status,
51 note: row.note.clone(),
52 decided_by: row.decided_by,
53 decided_at: row.decided_at,
54 created_at: row.created_at,
55 updated_at: row.updated_at,
56 })
57 }
58
59 // 加入规则的可选字段本身就多,参数表与 HTTP 请求体一一对应,拆结构体只会增加噪音。
60 #[allow(clippy::too_many_arguments)]
61 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
62 pub async fn create_join_rule(
63 &self,
64 actor_user_id: i64,
65 organization_id: Uuid,
66 role_id: Uuid,
67 trigger: String,
68 matcher: String,
69 enabled: bool,
70 request_id: Option<&str>,
71 ) -> AuthResult<JoinRuleSummary> {
72 self.require_permission(actor_user_id, organization_id, "role.update", request_id)
73 .await?;
74 let trigger = trigger.trim().to_string();
75 let matcher = matcher.trim().to_string();
76 if trigger.is_empty() || matcher.is_empty() {
77 return Err(AuthError::MalformedRequest);
78 }
79 let role = organization_roles::Entity::find_by_id(role_id)
80 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
81 .one(self.db().read())
82 .await?
83 .ok_or(AuthError::OrganizationRoleNotFound)?;
84 let row = organization_join_rules::ActiveModel {
85 organization_id: Set(organization_id),
86 role_id: Set(role.id),
87 trigger: Set(trigger),
88 matcher: Set(matcher),
89 enabled: Set(enabled),
90 ..Default::default()
91 }
92 .insert(self.db().write())
93 .await?;
94 service::bump_version(self.db().write(), organization_id).await?;
95 Ok(rule_summary(&row))
96 }
97
98 // 同上:可选字段多,保持与请求体一致的参数顺序。
99 #[allow(clippy::too_many_arguments)]
100 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, rule_id = %rule_id, request_id = %request_id.unwrap_or_default()))]
101 pub async fn update_join_rule(
102 &self,
103 actor_user_id: i64,
104 organization_id: Uuid,
105 rule_id: Uuid,
106 role_id: Option<Uuid>,
107 trigger: Option<String>,
108 matcher: Option<String>,
109 enabled: Option<bool>,
110 request_id: Option<&str>,
111 ) -> AuthResult<JoinRuleSummary> {
112 self.require_permission(actor_user_id, organization_id, "role.update", request_id)
113 .await?;
114 let row = organization_join_rules::Entity::find_by_id(rule_id)
115 .filter(organization_join_rules::Column::OrganizationId.eq(organization_id))
116 .one(self.db().read())
117 .await?
118 .ok_or(AuthError::OrganizationNotFound)?;
119 let mut active: organization_join_rules::ActiveModel = row.into();
120 if let Some(role_id) = role_id {
121 let role = organization_roles::Entity::find_by_id(role_id)
122 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
123 .one(self.db().read())
124 .await?
125 .ok_or(AuthError::OrganizationRoleNotFound)?;
126 active.role_id = Set(role.id);
127 }
128 if let Some(trigger) = trigger {
129 let trigger = trigger.trim().to_string();
130 if trigger.is_empty() {
131 return Err(AuthError::MalformedRequest);
132 }
133 active.trigger = Set(trigger);
134 }
135 if let Some(matcher) = matcher {
136 let matcher = matcher.trim().to_string();
137 if matcher.is_empty() {
138 return Err(AuthError::MalformedRequest);
139 }
140 active.matcher = Set(matcher);
141 }
142 if let Some(enabled) = enabled {
143 active.enabled = Set(enabled);
144 }
145 let updated = active.update(self.db().write()).await?;
146 service::bump_version(self.db().write(), organization_id).await?;
147 Ok(rule_summary(&updated))
148 }
149
150 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, rule_id = %rule_id, request_id = %request_id.unwrap_or_default()))]
151 pub async fn delete_join_rule(
152 &self,
153 actor_user_id: i64,
154 organization_id: Uuid,
155 rule_id: Uuid,
156 request_id: Option<&str>,
157 ) -> AuthResult<bool> {
158 self.require_permission(actor_user_id, organization_id, "role.update", request_id)
159 .await?;
160 let deleted = organization_join_rules::Entity::delete_many()
161 .filter(organization_join_rules::Column::Id.eq(rule_id))
162 .filter(organization_join_rules::Column::OrganizationId.eq(organization_id))
163 .exec(self.db().write())
164 .await?;
165 if deleted.rows_affected > 0 {
166 service::bump_version(self.db().write(), organization_id).await?;
167 }
168 Ok(deleted.rows_affected > 0)
169 }
170
171 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
172 pub async fn list_join_rules(
173 &self,
174 actor_user_id: i64,
175 organization_id: Uuid,
176 request_id: Option<&str>,
177 ) -> AuthResult<Vec<JoinRuleSummary>> {
178 self.require_permission(actor_user_id, organization_id, "role.view", request_id)
179 .await?;
180 let rows = organization_join_rules::Entity::find()
181 .filter(organization_join_rules::Column::OrganizationId.eq(organization_id))
182 .order_by_asc(organization_join_rules::Column::CreatedAt)
183 .all(self.db().read())
184 .await?;
185 Ok(rows.iter().map(rule_summary).collect())
186 }
187
188 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
189 pub async fn create_join_request(
190 &self,
191 actor_user_id: i64,
192 organization_id: Uuid,
193 note: Option<String>,
194 request_id: Option<&str>,
195 ) -> AuthResult<JoinRequestSummary> {
196 let organization = self.load_organization(organization_id).await?;
197 if organization.status != crate::models::space::OrganizationStatus::Active {
198 return Err(AuthError::OrganizationInactive);
199 }
200 if let Some(existing) =
201 member::find_any(self.db().read(), organization_id, actor_user_id).await?
202 {
203 if existing.banned_at.is_some() {
204 return Err(AuthError::OrganizationMemberBanned);
205 }
206 if existing.left_at.is_none() {
207 return Err(AuthError::OrganizationMemberExists);
208 }
209 }
210 if let Some(pending) = organization_join_requests::Entity::find()
211 .filter(organization_join_requests::Column::OrganizationId.eq(organization_id))
212 .filter(organization_join_requests::Column::UserId.eq(actor_user_id))
213 .filter(
214 organization_join_requests::Column::Status
215 .eq(OrganizationJoinRequestStatus::Pending),
216 )
217 .one(self.db().read())
218 .await?
219 {
220 let mut active: organization_join_requests::ActiveModel = pending.into();
221 active.note = Set(note);
222 let updated = active.update(self.db().write()).await?;
223 return self.join_request_summary(&updated).await;
224 }
225 let row = organization_join_requests::ActiveModel {
226 organization_id: Set(organization_id),
227 user_id: Set(actor_user_id),
228 status: Set(OrganizationJoinRequestStatus::Pending),
229 note: Set(note),
230 ..Default::default()
231 }
232 .insert(self.db().write())
233 .await?;
234 self.join_request_summary(&row).await
235 }
236
237 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
238 pub async fn list_join_requests(
239 &self,
240 actor_user_id: i64,
241 organization_id: Uuid,
242 status: Option<OrganizationJoinRequestStatus>,
243 limit: u64,
244 offset: u64,
245 request_id: Option<&str>,
246 ) -> AuthResult<(Vec<JoinRequestSummary>, u64)> {
247 self.require_permission(actor_user_id, organization_id, "member.view", request_id)
248 .await?;
249 let mut select = organization_join_requests::Entity::find()
250 .filter(organization_join_requests::Column::OrganizationId.eq(organization_id));
251 if let Some(status) = status {
252 select = select.filter(organization_join_requests::Column::Status.eq(status));
253 }
254 let total = select.clone().count(self.db().read()).await?;
255 let rows = select
256 .order_by_desc(organization_join_requests::Column::CreatedAt)
257 .limit(limit)
258 .offset(offset)
259 .all(self.db().read())
260 .await?;
261 let mut out = Vec::with_capacity(rows.len());
262 for row in rows {
263 out.push(self.join_request_summary(&row).await?);
264 }
265 Ok((out, total))
266 }
267
268 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, join_request_id = %join_request_id, request_id = %request_id.unwrap_or_default()))]
269 pub async fn withdraw_join_request(
270 &self,
271 actor_user_id: i64,
272 organization_id: Uuid,
273 join_request_id: Uuid,
274 request_id: Option<&str>,
275 ) -> AuthResult<JoinRequestSummary> {
276 let row = organization_join_requests::Entity::find_by_id(join_request_id)
277 .filter(organization_join_requests::Column::OrganizationId.eq(organization_id))
278 .one(self.db().read())
279 .await?
280 .ok_or(AuthError::OrganizationMemberNotFound)?;
281 if row.user_id != actor_user_id {
282 return Err(AuthError::Forbidden);
283 }
284 if row.status != OrganizationJoinRequestStatus::Pending {
285 return Err(AuthError::Forbidden);
286 }
287 let mut active: organization_join_requests::ActiveModel = row.into();
288 active.status = Set(OrganizationJoinRequestStatus::Withdrawn);
289 let updated = active.update(self.db().write()).await?;
290 self.join_request_summary(&updated).await
291 }
292
293 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, join_request_id = %join_request_id, request_id = %request_id.unwrap_or_default()))]
294 async fn finish_join_request(
295 &self,
296 actor_user_id: i64,
297 organization_id: Uuid,
298 join_request_id: Uuid,
299 status: OrganizationJoinRequestStatus,
300 note: Option<String>,
301 request_id: Option<&str>,
302 ) -> AuthResult<JoinRequestSummary> {
303 self.require_permission(actor_user_id, organization_id, "member.add", request_id)
304 .await?;
305 let row = organization_join_requests::Entity::find_by_id(join_request_id)
306 .filter(organization_join_requests::Column::OrganizationId.eq(organization_id))
307 .one(self.db().read())
308 .await?
309 .ok_or(AuthError::OrganizationMemberNotFound)?;
310 if row.status != OrganizationJoinRequestStatus::Pending {
311 return Err(AuthError::Forbidden);
312 }
313 if status == OrganizationJoinRequestStatus::Approved {
314 let default_role = organization_roles::Entity::find()
315 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
316 .filter(organization_roles::Column::Key.eq(SYSTEM_ROLE_MEMBER))
317 .one(self.db().read())
318 .await?;
319 self.add_member(
320 actor_user_id,
321 organization_id,
322 AddMemberBody {
323 user_id: row.user_id,
324 role: Some(OrgMemberRole::Member),
325 role_keys: default_role
326 .filter(|role| role.default_role)
327 .map(|role| vec![role.key])
328 .unwrap_or_default(),
329 },
330 request_id,
331 )
332 .await?;
333 }
334 let mut active: organization_join_requests::ActiveModel = row.into();
335 active.status = Set(status);
336 active.decided_by = Set(Some(actor_user_id));
337 active.decided_at = Set(Some(Utc::now()));
338 if let Some(note) = note {
339 active.note = Set(Some(note));
340 }
341 let updated = active.update(self.db().write()).await?;
342 self.join_request_summary(&updated).await
343 }
344
345 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, join_request_id = %join_request_id, request_id = %request_id.unwrap_or_default()))]
346 pub async fn approve_join_request(
347 &self,
348 actor_user_id: i64,
349 organization_id: Uuid,
350 join_request_id: Uuid,
351 request_id: Option<&str>,
352 ) -> AuthResult<JoinRequestSummary> {
353 self.finish_join_request(
354 actor_user_id,
355 organization_id,
356 join_request_id,
357 OrganizationJoinRequestStatus::Approved,
358 None,
359 request_id,
360 )
361 .await
362 }
363
364 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, join_request_id = %join_request_id, request_id = %request_id.unwrap_or_default()))]
365 pub async fn reject_join_request(
366 &self,
367 actor_user_id: i64,
368 organization_id: Uuid,
369 join_request_id: Uuid,
370 note: Option<String>,
371 request_id: Option<&str>,
372 ) -> AuthResult<JoinRequestSummary> {
373 self.finish_join_request(
374 actor_user_id,
375 organization_id,
376 join_request_id,
377 OrganizationJoinRequestStatus::Rejected,
378 note,
379 request_id,
380 )
381 .await
382 }
383
384 #[tracing::instrument(skip_all, fields(organization_id = %organization_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
385 pub async fn ensure_bot_member(
386 &self,
387 organization_id: Uuid,
388 user_id: i64,
389 role_keys: Vec<String>,
390 role_ids: Vec<String>,
391 request_id: Option<&str>,
392 ) -> AuthResult<(
393 crate::handlers::identity::organization::types::MemberSummary,
394 bool,
395 )> {
396 let txn = self.db().write().begin().await?;
397 // 机器人成员的角色上限:OWNER 只能由人类走成员面转移,不能通过内部接口直接塞入。
398 let user = crate::models::users::users::Entity::find_by_id(user_id)
399 .filter(crate::models::users::users::Column::DeletedAt.is_null())
400 .one(&txn)
401 .await?
402 .ok_or(AuthError::UserNotFound)?;
403 if !user.bot {
404 return Err(AuthError::Forbidden);
405 }
406 let (row, created) = member::upsert_join(
407 &txn,
408 organization_id,
409 user_id,
410 OrgMemberRole::Member,
411 OrgJoinMethod::Direct,
412 None,
413 None,
414 )
415 .await?;
416 let mut roles = Vec::new();
417 for key in role_keys {
418 if let Some(role) = organization_roles::Entity::find()
419 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
420 .filter(organization_roles::Column::Key.eq(key))
421 .one(&txn)
422 .await?
423 {
424 if role.key == SYSTEM_ROLE_OWNER {
425 return Err(AuthError::Forbidden);
426 }
427 roles.push(role);
428 }
429 }
430 for id in role_ids {
431 let Ok(id) = Uuid::parse_str(id.trim()) else {
432 continue;
433 };
434 if let Some(role) = organization_roles::Entity::find_by_id(id)
435 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
436 .one(&txn)
437 .await?
438 {
439 if role.key == SYSTEM_ROLE_OWNER {
440 return Err(AuthError::Forbidden);
441 }
442 roles.push(role);
443 }
444 }
445 for role in roles {
446 let existing = crate::models::space::organization_member_roles::Entity::find()
447 .filter(
448 crate::models::space::organization_member_roles::Column::MemberId.eq(row.id),
449 )
450 .filter(crate::models::space::organization_member_roles::Column::RoleId.eq(role.id))
451 .one(&txn)
452 .await?;
453 if existing.is_none() {
454 crate::models::space::organization_member_roles::ActiveModel {
455 member_id: Set(row.id),
456 role_id: Set(role.id),
457 ..Default::default()
458 }
459 .insert(&txn)
460 .await?;
461 }
462 }
463 service::bump_version(&txn, organization_id).await?;
464 txn.commit().await?;
465 let summary = self.member_summary(&row).await?;
466 Ok((summary, created))
467 }
468
469 #[tracing::instrument(skip_all, fields(organization_id = %organization_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
470 pub async fn touch_member_activity(
471 &self,
472 organization_id: Uuid,
473 user_id: i64,
474 request_id: Option<&str>,
475 ) {
476 let _ = organization_members::Entity::update_many()
477 .col_expr(
478 organization_members::Column::LastActiveAt,
479 Expr::value(Utc::now()),
480 )
481 .filter(organization_members::Column::OrganizationId.eq(organization_id))
482 .filter(organization_members::Column::UserId.eq(user_id))
483 .exec(self.db().write())
484 .await;
485 }
486}
Deleted lib/auth/src/handlers/identity/organization/member.rs
@@ -1,939 +0,0 @@
1use chrono::Utc;
2use sea_orm::entity::Set;
3use sea_orm::sea_query::{Expr, ExprTrait};
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder,
6 QuerySelect, TransactionTrait,
7};
8use uuid::Uuid;
9
10use crate::AuthSvc;
11use crate::errors::{AuthError, AuthResult};
12use crate::events::label;
13use crate::handlers::identity::organization::role;
14use crate::handlers::identity::organization::service::{bump_version, normalize_reason};
15use crate::handlers::identity::organization::types::{
16 AddMemberBody, MAX_ALIAS_AVATAR_URL_LEN, MAX_ALIAS_NAME_LEN, MAX_REASON_LEN, MemberSummary,
17 SetMemberRoleBody, UpdateBanBody, UpdateMemberBody, normalize_alias,
18};
19use crate::models::space::{
20 OrgJoinMethod, OrgMemberRole, organization_member_permissions, organization_members,
21};
22use crate::models::users::users;
23
24pub(crate) async fn find_any<C: ConnectionTrait>(
25 db: &C,
26 organization_id: Uuid,
27 user_id: i64,
28) -> AuthResult<Option<organization_members::Model>> {
29 Ok(organization_members::Entity::find()
30 .filter(organization_members::Column::OrganizationId.eq(organization_id))
31 .filter(organization_members::Column::UserId.eq(user_id))
32 .one(db)
33 .await?)
34}
35
36pub(crate) async fn find_active<C: ConnectionTrait>(
37 db: &C,
38 organization_id: Uuid,
39 user_id: i64,
40) -> AuthResult<Option<organization_members::Model>> {
41 Ok(organization_members::Entity::find()
42 .filter(organization_members::Column::OrganizationId.eq(organization_id))
43 .filter(organization_members::Column::UserId.eq(user_id))
44 .filter(organization_members::Column::LeftAt.is_null())
45 .filter(organization_members::Column::BannedAt.is_null())
46 .one(db)
47 .await?)
48}
49
50pub(crate) async fn load_by_id<C: ConnectionTrait>(
51 db: &C,
52 organization_id: Uuid,
53 member_id: Uuid,
54) -> AuthResult<organization_members::Model> {
55 organization_members::Entity::find_by_id(member_id)
56 .filter(organization_members::Column::OrganizationId.eq(organization_id))
57 .one(db)
58 .await?
59 .ok_or(AuthError::OrganizationMemberNotFound)
60}
61
62pub(crate) async fn insert_owner<C: ConnectionTrait>(
63 db: &C,
64 organization_id: Uuid,
65 user_id: i64,
66) -> AuthResult<organization_members::Model> {
67 Ok(organization_members::ActiveModel {
68 organization_id: Set(organization_id),
69 user_id: Set(user_id),
70 role: Set(OrgMemberRole::Owner),
71 joined_at: Set(Utc::now()),
72 join_method: Set(OrgJoinMethod::Direct),
73 ..Default::default()
74 }
75 .insert(db)
76 .await?)
77}
78
79/// 会籍写入的统一入口:已有行就复活,没有就新建。
80/// `left_at` / `banned_at` 同时清空才算复活,两个字段各自记录一次"被赶出去"的原因,
81/// 直接覆盖会丢掉历史,所以只清标志位,历史留在事件与兑换记录里。
82pub(crate) async fn upsert_join<C: ConnectionTrait>(
83 db: &C,
84 organization_id: Uuid,
85 user_id: i64,
86 role: OrgMemberRole,
87 method: OrgJoinMethod,
88 invited_by: Option<i64>,
89 invite_id: Option<Uuid>,
90) -> AuthResult<(organization_members::Model, bool)> {
91 match find_any(db, organization_id, user_id).await? {
92 Some(existing) => {
93 if existing.banned_at.is_some() {
94 return Err(AuthError::OrganizationMemberBanned);
95 }
96 if existing.left_at.is_none() {
97 return Ok((existing, false));
98 }
99 let mut active: organization_members::ActiveModel = existing.into();
100 active.role = Set(role);
101 active.joined_at = Set(Utc::now());
102 active.left_at = Set(None);
103 active.ban_reason = Set(None);
104 active.invited_by = Set(invited_by);
105 active.join_method = Set(method);
106 active.invite_id = Set(invite_id);
107 active.version = Set(active.version.unwrap() + 1);
108 Ok((active.update(db).await?, true))
109 }
110 None => {
111 let row = organization_members::ActiveModel {
112 organization_id: Set(organization_id),
113 user_id: Set(user_id),
114 role: Set(role),
115 joined_at: Set(Utc::now()),
116 invited_by: Set(invited_by),
117 join_method: Set(method),
118 invite_id: Set(invite_id),
119 ..Default::default()
120 }
121 .insert(db)
122 .await?;
123 Ok((row, true))
124 }
125 }
126}
127
128impl AuthSvc {
129 // 分页与过滤开关直接对应查询参数,拆结构体没有收益。
130 #[allow(clippy::too_many_arguments)]
131 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
132 pub async fn list_members(
133 &self,
134 actor_user_id: i64,
135 organization_id: Uuid,
136 include_inactive: bool,
137 include_banned: bool,
138 limit: u64,
139 offset: u64,
140 request_id: Option<&str>,
141 ) -> AuthResult<Vec<MemberSummary>> {
142 self.require_permission(actor_user_id, organization_id, "member.view", request_id)
143 .await?;
144 let mut query = organization_members::Entity::find()
145 .filter(organization_members::Column::OrganizationId.eq(organization_id));
146 if !include_inactive {
147 query = query.filter(organization_members::Column::LeftAt.is_null());
148 }
149 if !include_banned {
150 query = query.filter(organization_members::Column::BannedAt.is_null());
151 }
152 let rows = query
153 .order_by_asc(organization_members::Column::JoinedAt)
154 .limit(limit)
155 .offset(offset)
156 .all(self.db().read())
157 .await?;
158 let mut out = Vec::with_capacity(rows.len());
159 for row in rows {
160 out.push(self.member_summary(&row).await?);
161 }
162 Ok(out)
163 }
164
165 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
166 pub async fn add_member(
167 &self,
168 actor_user_id: i64,
169 organization_id: Uuid,
170 body: AddMemberBody,
171 request_id: Option<&str>,
172 ) -> AuthResult<(MemberSummary, bool)> {
173 self.require_permission(actor_user_id, organization_id, "member.add", request_id)
174 .await?;
175 let role = body.role.unwrap_or(OrgMemberRole::Member);
176 if role == OrgMemberRole::Owner {
177 return Err(AuthError::Forbidden);
178 }
179 // 角色绑定属于 `role.assign` 范畴:只有 `member.add` 的人不能把新成员直接绑进高权角色。
180 if !body.role_keys.is_empty() {
181 self.require_permission(actor_user_id, organization_id, "role.assign", request_id)
182 .await?;
183 }
184 let role_keys = role::load_keys(organization_id, &body.role_keys, self.db().read()).await?;
185 users::Entity::find_by_id(body.user_id)
186 .filter(users::Column::DeletedAt.is_null())
187 .one(self.db().read())
188 .await?
189 .ok_or(AuthError::UserNotFound)?;
190
191 let txn = self.db().write().begin().await?;
192 let (row, created) = upsert_join(
193 &txn,
194 organization_id,
195 body.user_id,
196 role,
197 OrgJoinMethod::Direct,
198 Some(actor_user_id),
199 None,
200 )
201 .await?;
202 for role_id in &role_keys {
203 role::bind_role(&txn, row.id, *role_id, actor_user_id).await?;
204 }
205 if created && let Some(default_role) = self.default_role_id(organization_id).await? {
206 role::bind_role(&txn, row.id, default_role, actor_user_id).await?;
207 }
208 bump_version(&txn, organization_id).await?;
209 let refreshed = load_by_id(&txn, organization_id, row.id).await?;
210 self.emit_member(
211 &txn,
212 organization_id,
213 actor_user_id,
214 serde_json::json!({
215 "id": refreshed.id,
216 "user_id": refreshed.user_id,
217 "action": "add",
218 "created": created,
219 "request_id": request_id,
220 }),
221 request_id,
222 )
223 .await?;
224 txn.commit().await?;
225
226 self.invalidate_org(organization_id).await?;
227 // 返回的 version 必须是成员行自己的版本:组织版本对齐的是另一条乐观锁链。
228 let summary = self.member_summary(&refreshed).await?;
229 Ok((summary, created))
230 }
231
232 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
233 pub async fn remove_member(
234 &self,
235 actor_user_id: i64,
236 organization_id: Uuid,
237 member_id: Option<Uuid>,
238 user_id: i64,
239 reason: Option<&str>,
240 request_id: Option<&str>,
241 ) -> AuthResult<bool> {
242 self.require_permission(actor_user_id, organization_id, "member.remove", request_id)
243 .await?;
244 let reason = normalize_reason(reason)?;
245 let row = match member_id {
246 Some(id) => load_by_id(self.db().read(), organization_id, id).await?,
247 None => find_any(self.db().read(), organization_id, user_id)
248 .await?
249 .ok_or(AuthError::OrganizationMemberNotFound)?,
250 };
251 if row.role == OrgMemberRole::Owner {
252 return Err(AuthError::OrganizationOwnerRequired);
253 }
254 if row.left_at.is_some() {
255 return Ok(false);
256 }
257
258 let txn = self.db().write().begin().await?;
259 organization_members::Entity::update_many()
260 .col_expr(
261 organization_members::Column::LeftAt,
262 Expr::value(Utc::now()),
263 )
264 .col_expr(
265 organization_members::Column::BanReason,
266 Expr::value(reason.clone()),
267 )
268 .col_expr(
269 organization_members::Column::Version,
270 Expr::col(organization_members::Column::Version).add(1),
271 )
272 .filter(organization_members::Column::Id.eq(row.id))
273 .exec(&txn)
274 .await?;
275 // 离组即失去全部授予:不清掉绑定,复活时会带着旧角色回来,等于悄悄恢复权限。
276 role::clear_bindings(&txn, row.id).await?;
277 organization_member_permissions::Entity::delete_many()
278 .filter(organization_member_permissions::Column::MemberId.eq(row.id))
279 .exec(&txn)
280 .await?;
281 let version = bump_version(&txn, organization_id).await?;
282 self.emit_member(
283 &txn,
284 organization_id,
285 actor_user_id,
286 serde_json::json!({
287 "id": row.id,
288 "user_id": row.user_id,
289 "action": "remove",
290 "version": version,
291 "request_id": request_id,
292 }),
293 request_id,
294 )
295 .await?;
296 txn.commit().await?;
297
298 self.invalidate_org(organization_id).await?;
299 Ok(true)
300 }
301
302 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
303 pub async fn leave_organization(
304 &self,
305 actor_user_id: i64,
306 organization_id: Uuid,
307 request_id: Option<&str>,
308 ) -> AuthResult<bool> {
309 let row = find_any(self.db().read(), organization_id, actor_user_id)
310 .await?
311 .ok_or(AuthError::OrganizationMemberNotFound)?;
312 if row.role == OrgMemberRole::Owner {
313 return Err(AuthError::OrganizationOwnerRequired);
314 }
315 if row.left_at.is_some() {
316 return Ok(false);
317 }
318
319 let txn = self.db().write().begin().await?;
320 organization_members::Entity::update_many()
321 .col_expr(
322 organization_members::Column::LeftAt,
323 Expr::value(Utc::now()),
324 )
325 .col_expr(
326 organization_members::Column::Version,
327 Expr::col(organization_members::Column::Version).add(1),
328 )
329 .filter(organization_members::Column::Id.eq(row.id))
330 .exec(&txn)
331 .await?;
332 role::clear_bindings(&txn, row.id).await?;
333 organization_member_permissions::Entity::delete_many()
334 .filter(organization_member_permissions::Column::MemberId.eq(row.id))
335 .exec(&txn)
336 .await?;
337 let version = bump_version(&txn, organization_id).await?;
338 self.emit_member(
339 &txn,
340 organization_id,
341 actor_user_id,
342 serde_json::json!({
343 "id": row.id,
344 "user_id": row.user_id,
345 "action": "leave",
346 "version": version,
347 "request_id": request_id,
348 }),
349 request_id,
350 )
351 .await?;
352 txn.commit().await?;
353
354 self.invalidate_org(organization_id).await?;
355 Ok(true)
356 }
357
358 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, member_id = %member_id, request_id = %request_id.unwrap_or_default()))]
359 pub async fn set_member_role(
360 &self,
361 actor_user_id: i64,
362 organization_id: Uuid,
363 member_id: Uuid,
364 body: SetMemberRoleBody,
365 request_id: Option<&str>,
366 ) -> AuthResult<MemberSummary> {
367 self.require_permission(
368 actor_user_id,
369 organization_id,
370 "member.role.update",
371 request_id,
372 )
373 .await?;
374 if body.role == OrgMemberRole::Owner {
375 return Err(AuthError::Forbidden);
376 }
377 let row = load_by_id(self.db().read(), organization_id, member_id).await?;
378 if row.role == OrgMemberRole::Owner {
379 return Err(AuthError::OrganizationOwnerRequired);
380 }
381 if body.expected_version > 0 && row.version != body.expected_version {
382 return Err(AuthError::VersionMismatch {
383 current: row.version,
384 });
385 }
386
387 let owner_role = role::find_by_key(organization_id, "owner", self.db().read()).await?;
388 let admin_role = role::find_by_key(organization_id, "admin", self.db().read()).await?;
389 let member_role = role::find_by_key(organization_id, "member", self.db().read()).await?;
390
391 let txn = self.db().write().begin().await?;
392 organization_members::Entity::update_many()
393 .col_expr(organization_members::Column::Role, Expr::value(body.role))
394 .col_expr(
395 organization_members::Column::Version,
396 Expr::col(organization_members::Column::Version).add(1),
397 )
398 .filter(organization_members::Column::Id.eq(member_id))
399 .exec(&txn)
400 .await?;
401 // 粗粒度角色与系统角色绑定必须同步,否则解析链上会出现"粗粒度是 ADMIN、
402 // 绑定里还留着 member"这种自相矛盾的组合。
403 let target = match body.role {
404 OrgMemberRole::Admin => admin_role.clone(),
405 _ => member_role.clone(),
406 };
407 for system in [owner_role, admin_role, member_role].into_iter().flatten() {
408 role::unbind_role(&txn, member_id, system.id).await?;
409 }
410 if let Some(system) = target {
411 role::bind_role(&txn, member_id, system.id, actor_user_id).await?;
412 }
413 let version = bump_version(&txn, organization_id).await?;
414 let refreshed = load_by_id(&txn, organization_id, member_id).await?;
415 self.emit_member(
416 &txn,
417 organization_id,
418 actor_user_id,
419 serde_json::json!({
420 "id": member_id,
421 "user_id": row.user_id,
422 "action": "set_role",
423 "role": refreshed.role,
424 "request_id": request_id,
425 }),
426 request_id,
427 )
428 .await?;
429 txn.commit().await?;
430
431 self.invalidate_org(organization_id).await?;
432 let mut summary = self.member_summary(&refreshed).await?;
433 summary.version = version;
434 Ok(summary)
435 }
436
437 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, member_id = %member_id, request_id = %request_id.unwrap_or_default()))]
438 pub async fn ban_member(
439 &self,
440 actor_user_id: i64,
441 organization_id: Uuid,
442 member_id: Uuid,
443 reason: Option<&str>,
444 request_id: Option<&str>,
445 ) -> AuthResult<MemberSummary> {
446 self.require_permission(actor_user_id, organization_id, "member.ban", request_id)
447 .await?;
448 let reason = match reason.map(str::trim) {
449 None | Some("") => None,
450 Some(value) if value.len() > MAX_REASON_LEN => return Err(AuthError::MalformedRequest),
451 Some(value) => Some(value.to_owned()),
452 };
453 let row = load_by_id(self.db().read(), organization_id, member_id).await?;
454 if row.role == OrgMemberRole::Owner {
455 return Err(AuthError::OrganizationOwnerRequired);
456 }
457 if row.banned_at.is_some() {
458 return self.member_summary(&row).await;
459 }
460
461 let txn = self.db().write().begin().await?;
462 organization_members::Entity::update_many()
463 .col_expr(
464 organization_members::Column::BannedAt,
465 Expr::value(Utc::now()),
466 )
467 .col_expr(
468 organization_members::Column::BanReason,
469 Expr::value(reason.clone()),
470 )
471 .col_expr(
472 organization_members::Column::Version,
473 Expr::col(organization_members::Column::Version).add(1),
474 )
475 .filter(organization_members::Column::Id.eq(member_id))
476 .exec(&txn)
477 .await?;
478 // 封禁优先于一切授权,清空授予让"解封后权限自己回来"不可能发生。
479 role::clear_bindings(&txn, member_id).await?;
480 organization_member_permissions::Entity::delete_many()
481 .filter(organization_member_permissions::Column::MemberId.eq(member_id))
482 .exec(&txn)
483 .await?;
484 let version = bump_version(&txn, organization_id).await?;
485 let refreshed = load_by_id(&txn, organization_id, member_id).await?;
486 self.emit_member(
487 &txn,
488 organization_id,
489 actor_user_id,
490 serde_json::json!({
491 "id": member_id,
492 "user_id": row.user_id,
493 "action": "ban",
494 "reason": &reason,
495 "request_id": request_id,
496 }),
497 request_id,
498 )
499 .await?;
500 txn.commit().await?;
501
502 self.invalidate_org(organization_id).await?;
503 let mut summary = self.member_summary(&refreshed).await?;
504 summary.version = version;
505 Ok(summary)
506 }
507
508 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, member_id = %member_id, request_id = %request_id.unwrap_or_default()))]
509 pub async fn unban_member(
510 &self,
511 actor_user_id: i64,
512 organization_id: Uuid,
513 member_id: Uuid,
514 request_id: Option<&str>,
515 ) -> AuthResult<MemberSummary> {
516 self.require_permission(actor_user_id, organization_id, "member.ban", request_id)
517 .await?;
518 let row = load_by_id(self.db().read(), organization_id, member_id).await?;
519 if row.banned_at.is_none() {
520 return self.member_summary(&row).await;
521 }
522
523 let txn = self.db().write().begin().await?;
524 // 解封必须把封禁的全部附带状态一起清掉,否则残留的到期时间与四个 allow_* 位会继续生效。
525 let unlocked = organization_members::Entity::update_many()
526 .col_expr(
527 organization_members::Column::BannedAt,
528 Expr::value(None::<chrono::DateTime<Utc>>),
529 )
530 .col_expr(
531 organization_members::Column::BanReason,
532 Expr::value(None::<String>),
533 )
534 .col_expr(
535 organization_members::Column::BanExpiresAt,
536 Expr::value(None::<chrono::DateTime<Utc>>),
537 )
538 .col_expr(
539 organization_members::Column::BanAllowAgentJoin,
540 Expr::value(false),
541 )
542 .col_expr(
543 organization_members::Column::BanAllowSpeak,
544 Expr::value(false),
545 )
546 .col_expr(
547 organization_members::Column::BanAllowPushRepo,
548 Expr::value(false),
549 )
550 .col_expr(
551 organization_members::Column::BanAllowCommentAny,
552 Expr::value(false),
553 )
554 .col_expr(
555 organization_members::Column::Version,
556 Expr::col(organization_members::Column::Version).add(1),
557 )
558 .filter(organization_members::Column::Id.eq(member_id))
559 .filter(organization_members::Column::BannedAt.is_not_null())
560 .exec(&txn)
561 .await?;
562 if unlocked.rows_affected != 1 {
563 txn.rollback().await?;
564 return Err(AuthError::VersionMismatch {
565 current: row.version,
566 });
567 }
568 bump_version(&txn, organization_id).await?;
569 let refreshed = load_by_id(&txn, organization_id, member_id).await?;
570 self.emit_member(
571 &txn,
572 organization_id,
573 actor_user_id,
574 serde_json::json!({
575 "id": member_id,
576 "user_id": row.user_id,
577 "action": "unban",
578 "request_id": request_id,
579 }),
580 request_id,
581 )
582 .await?;
583 txn.commit().await?;
584
585 self.invalidate_org(organization_id).await?;
586 let summary = self.member_summary(&refreshed).await?;
587 Ok(summary)
588 }
589
590 /// 按会籍 id 或 user_id 读会籍;两者都提供时以 member_id 为准。
591 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
592 pub async fn get_member(
593 &self,
594 actor_user_id: i64,
595 organization_id: Uuid,
596 member_id: Option<Uuid>,
597 user_id: i64,
598 request_id: Option<&str>,
599 ) -> AuthResult<MemberSummary> {
600 self.require_permission(actor_user_id, organization_id, "member.view", request_id)
601 .await?;
602 let row = match member_id {
603 Some(id) => load_by_id(self.db().read(), organization_id, id).await?,
604 None => find_any(self.db().read(), organization_id, user_id)
605 .await?
606 .ok_or(AuthError::OrganizationMemberNotFound)?,
607 };
608 self.member_summary(&row).await
609 }
610
611 /// 管理端更新会籍:别名、头像、通知偏好、免打扰。封禁中的会籍只走 `update_ban`。
612 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, member_id = %member_id, request_id = %request_id.unwrap_or_default()))]
613 pub async fn update_member(
614 &self,
615 actor_user_id: i64,
616 organization_id: Uuid,
617 member_id: Uuid,
618 body: UpdateMemberBody,
619 request_id: Option<&str>,
620 ) -> AuthResult<MemberSummary> {
621 self.require_permission(
622 actor_user_id,
623 organization_id,
624 "member.role.update",
625 request_id,
626 )
627 .await?;
628 self.apply_member_update(
629 actor_user_id,
630 organization_id,
631 member_id,
632 body,
633 "update",
634 request_id,
635 )
636 .await
637 }
638
639 /// 成员自助更新:只允许改自己的别名、头像与通知偏好。
640 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
641 pub async fn update_self_member(
642 &self,
643 actor_user_id: i64,
644 organization_id: Uuid,
645 body: UpdateMemberBody,
646 request_id: Option<&str>,
647 ) -> AuthResult<MemberSummary> {
648 let row = find_active(self.db().read(), organization_id, actor_user_id)
649 .await?
650 .ok_or(AuthError::Forbidden)?;
651 self.apply_member_update(
652 actor_user_id,
653 organization_id,
654 row.id,
655 body,
656 "self_update",
657 request_id,
658 )
659 .await
660 }
661
662 async fn apply_member_update(
663 &self,
664 actor_user_id: i64,
665 organization_id: Uuid,
666 member_id: Uuid,
667 body: UpdateMemberBody,
668 action: &'static str,
669 request_id: Option<&str>,
670 ) -> AuthResult<MemberSummary> {
671 // 读取、版本校验与写回必须在同一事务内,否则并发封禁/离组/改角色会被旧快照覆盖。
672 let txn = self.db().write().begin().await?;
673 let row = load_by_id(&txn, organization_id, member_id).await?;
674 if body.expected_version > 0 && row.version != body.expected_version {
675 txn.rollback().await?;
676 return Err(AuthError::VersionMismatch {
677 current: row.version,
678 });
679 }
680 if action == "update" {
681 if row.role == OrgMemberRole::Owner {
682 txn.rollback().await?;
683 return Err(AuthError::OrganizationOwnerRequired);
684 }
685 if row.banned_at.is_some() {
686 txn.rollback().await?;
687 return Err(AuthError::Forbidden);
688 }
689 } else if row.banned_at.is_some() || row.left_at.is_some() {
690 txn.rollback().await?;
691 return Err(AuthError::Forbidden);
692 }
693 let alias_name = match body.alias_name {
694 None => row.alias_name.clone(),
695 Some(None) => None,
696 Some(Some(value)) => normalize_alias_field(value, MAX_ALIAS_NAME_LEN)?,
697 };
698 let alias_avatar_url = match body.alias_avatar_url {
699 None => row.alias_avatar_url.clone(),
700 Some(None) => None,
701 Some(Some(value)) => normalize_alias_field(value, MAX_ALIAS_AVATAR_URL_LEN)?,
702 };
703 let message_notifications = body
704 .message_notifications
705 .unwrap_or(row.message_notifications);
706 let muted = body.muted.unwrap_or(row.muted);
707 let mute_until = match body.mute_until {
708 None => row.mute_until,
709 Some(value) => value,
710 };
711 let mobile_push = body.mobile_push.unwrap_or(row.mobile_push);
712 let suppress_everyone = body.suppress_everyone.unwrap_or(row.suppress_everyone);
713 let suppress_roles = body.suppress_roles.unwrap_or(row.suppress_roles);
714 let hide_muted_channels = body.hide_muted_channels.unwrap_or(row.hide_muted_channels);
715 // 条件写回:只有版本未变时才生效,防止旧快照覆盖并发已提交的封禁/离组/角色变更。
716 let written = organization_members::Entity::update_many()
717 .col_expr(
718 organization_members::Column::AliasName,
719 Expr::value(alias_name.clone()),
720 )
721 .col_expr(
722 organization_members::Column::AliasAvatarUrl,
723 Expr::value(alias_avatar_url.clone()),
724 )
725 .col_expr(
726 organization_members::Column::MessageNotifications,
727 Expr::value(message_notifications),
728 )
729 .col_expr(organization_members::Column::Muted, Expr::value(muted))
730 .col_expr(
731 organization_members::Column::MuteUntil,
732 Expr::value(mute_until),
733 )
734 .col_expr(
735 organization_members::Column::MobilePush,
736 Expr::value(mobile_push),
737 )
738 .col_expr(
739 organization_members::Column::SuppressEveryone,
740 Expr::value(suppress_everyone),
741 )
742 .col_expr(
743 organization_members::Column::SuppressRoles,
744 Expr::value(suppress_roles),
745 )
746 .col_expr(
747 organization_members::Column::HideMutedChannels,
748 Expr::value(hide_muted_channels),
749 )
750 .col_expr(
751 organization_members::Column::Version,
752 Expr::value(row.version + 1),
753 )
754 .filter(organization_members::Column::Id.eq(row.id))
755 .filter(organization_members::Column::Version.eq(row.version))
756 .exec(&txn)
757 .await?;
758 if written.rows_affected != 1 {
759 txn.rollback().await?;
760 return Err(AuthError::VersionMismatch {
761 current: row.version,
762 });
763 }
764 let version = bump_version(&txn, organization_id).await?;
765 let refreshed = load_by_id(&txn, organization_id, row.id).await?;
766 self.emit_member(
767 &txn,
768 organization_id,
769 actor_user_id,
770 serde_json::json!({
771 "id": row.id,
772 "user_id": row.user_id,
773 "action": action,
774 "alias_name": alias_name,
775 "alias_avatar_url": alias_avatar_url,
776 "request_id": request_id,
777 }),
778 request_id,
779 )
780 .await?;
781 txn.commit().await?;
782
783 self.invalidate_org(organization_id).await?;
784 let mut summary = self.member_summary(&refreshed).await?;
785 summary.version = version;
786 Ok(summary)
787 }
788
789 /// 封禁列表:只看 `banned_at` 非空的会籍。
790 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
791 pub async fn list_bans(
792 &self,
793 actor_user_id: i64,
794 organization_id: Uuid,
795 limit: u64,
796 offset: u64,
797 request_id: Option<&str>,
798 ) -> AuthResult<Vec<MemberSummary>> {
799 self.require_permission(actor_user_id, organization_id, "member.ban", request_id)
800 .await?;
801 let rows = organization_members::Entity::find()
802 .filter(organization_members::Column::OrganizationId.eq(organization_id))
803 .filter(organization_members::Column::BannedAt.is_not_null())
804 .order_by_desc(organization_members::Column::BannedAt)
805 .limit(limit)
806 .offset(offset)
807 .all(self.db().read())
808 .await?;
809 let mut out = Vec::with_capacity(rows.len());
810 for row in rows {
811 out.push(self.member_summary(&row).await?);
812 }
813 Ok(out)
814 }
815
816 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
817 pub async fn get_ban(
818 &self,
819 actor_user_id: i64,
820 organization_id: Uuid,
821 user_id: i64,
822 request_id: Option<&str>,
823 ) -> AuthResult<MemberSummary> {
824 self.require_permission(actor_user_id, organization_id, "member.ban", request_id)
825 .await?;
826 let row = find_any(self.db().read(), organization_id, user_id)
827 .await?
828 .ok_or(AuthError::OrganizationMemberNotFound)?;
829 if row.banned_at.is_none() {
830 return Err(AuthError::OrganizationMemberNotFound);
831 }
832 self.member_summary(&row).await
833 }
834
835 /// 改封禁原因、到期时间与四个放行位;会籍必须处于封禁中。
836 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
837 pub async fn update_ban(
838 &self,
839 actor_user_id: i64,
840 organization_id: Uuid,
841 user_id: i64,
842 body: UpdateBanBody,
843 request_id: Option<&str>,
844 ) -> AuthResult<MemberSummary> {
845 self.require_permission(actor_user_id, organization_id, "member.ban", request_id)
846 .await?;
847 let row = find_any(self.db().read(), organization_id, user_id)
848 .await?
849 .ok_or(AuthError::OrganizationMemberNotFound)?;
850 if row.banned_at.is_none() {
851 return Err(AuthError::OrganizationMemberNotFound);
852 }
853 let reason = match body.reason {
854 None => row.ban_reason.clone(),
855 Some(value) => match value {
856 None => None,
857 Some(value) => {
858 let trimmed = value.trim();
859 if trimmed.len() > MAX_REASON_LEN {
860 return Err(AuthError::MalformedRequest);
861 }
862 Some(trimmed.to_owned()).filter(|value| !value.is_empty())
863 }
864 },
865 };
866 let expires_at = match body.expires_at {
867 None => row.ban_expires_at,
868 Some(value) => value,
869 };
870
871 let txn = self.db().write().begin().await?;
872 let mut active: organization_members::ActiveModel = row.clone().into();
873 active.ban_reason = Set(reason.clone());
874 active.ban_expires_at = Set(expires_at);
875 if let Some(value) = body.allow_agent_join {
876 active.ban_allow_agent_join = Set(value);
877 }
878 if let Some(value) = body.allow_speak {
879 active.ban_allow_speak = Set(value);
880 }
881 if let Some(value) = body.allow_push_repo {
882 active.ban_allow_push_repo = Set(value);
883 }
884 if let Some(value) = body.allow_comment_any {
885 active.ban_allow_comment_any = Set(value);
886 }
887 active.version = Set(row.version + 1);
888 active.update(&txn).await?;
889 let version = bump_version(&txn, organization_id).await?;
890 let refreshed = load_by_id(&txn, organization_id, row.id).await?;
891 self.emit_member(
892 &txn,
893 organization_id,
894 actor_user_id,
895 serde_json::json!({
896 "id": row.id,
897 "user_id": row.user_id,
898 "action": "update_ban",
899 "expires_at": expires_at,
900 "request_id": request_id,
901 }),
902 request_id,
903 )
904 .await?;
905 txn.commit().await?;
906
907 self.invalidate_org(organization_id).await?;
908 let mut summary = self.member_summary(&refreshed).await?;
909 summary.version = version;
910 Ok(summary)
911 }
912
913 /// 成员事件与业务写入同事务入队:提交时一起可见,回滚时一起消失。
914 pub(crate) async fn emit_member(
915 &self,
916 txn: &sea_orm::DatabaseTransaction,
917 organization_id: Uuid,
918 actor_user_id: i64,
919 payload: serde_json::Value,
920 _request_id: Option<&str>,
921 ) -> AuthResult<()> {
922 let labels = crate::events::Labels::new()
923 .with(label::ORGANIZATION_ID, organization_id)
924 .with(label::ACTOR_USER_ID, actor_user_id);
925 self.emit_tx(
926 txn,
927 label::ORGANIZATION_MEMBER_UPDATE,
928 labels,
929 payload,
930 None,
931 )
932 .await
933 }
934}
935
936/// 别名/别名头像的规范化:空白视为清空,超长按 400 处理。
937fn normalize_alias_field(value: String, max: usize) -> AuthResult<Option<String>> {
938 normalize_alias(&value, "alias", max).map_err(|_| AuthError::MalformedRequest)
939}
Modified lib/auth/src/handlers/identity/organization/mod.rs View lib/auth/src/handlers/identity/organization/mod.rs
@@ -1,11 +1 @@
1pub mod error;
21pub mod handler;
3pub mod invite;
4pub mod join;
5pub mod member;
6pub mod permission;
7pub mod role;
8pub mod service;
9pub mod types;
10
11pub use types::*;
Deleted lib/auth/src/handlers/identity/organization/permission.rs
@@ -1,324 +0,0 @@
1use std::time::Duration;
2
3use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QuerySelect};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::AuthSvc;
8use crate::errors::{AuthError, AuthResult};
9use crate::handlers::identity::organization::types::{
10 PERMISSION_CACHE_TTL_SECS, PERMISSIONS, PermissionCheck, Resolution, SYSTEM_ROLE_ADMIN,
11 SYSTEM_ROLE_OWNER, is_admin_permission, is_known_permission,
12};
13use crate::handlers::identity::organization::{member, role, service};
14use crate::models::space::{
15 OrgMemberRole, PermissionEffect, organization_member_permissions, organization_member_roles,
16 organization_members, organization_role_permissions,
17};
18
19/// 缓存进 Redis 的判定核心。版本号相同的组织共享同一份核心,
20/// 任何写操作推进 `organizations.version` 都会让整份核心失效。
21#[derive(Clone, Debug, Default, Serialize, Deserialize)]
22pub(crate) struct Kernel {
23 pub version: i32,
24 pub is_owner: bool,
25 pub is_admin: bool,
26 pub role_keys: Vec<String>,
27 pub role_allow: Vec<String>,
28 pub role_deny: Vec<String>,
29 pub direct_allow: Vec<String>,
30 pub direct_deny: Vec<String>,
31}
32
33fn push_unique(target: &mut Vec<String>, value: &str) {
34 if !target.iter().any(|existing| existing == value) {
35 target.push(value.to_owned());
36 }
37}
38
39pub(crate) async fn load_kernel<C: ConnectionTrait>(
40 db: &C,
41 version: i32,
42 membership: &organization_members::Model,
43) -> AuthResult<Kernel> {
44 let role_ids = organization_member_roles::Entity::find()
45 .select_only()
46 .column(organization_member_roles::Column::RoleId)
47 .filter(organization_member_roles::Column::MemberId.eq(membership.id))
48 .into_tuple::<Uuid>()
49 .all(db)
50 .await?;
51 let role_keys = role::keys_of_ids(db, &role_ids).await?;
52 let is_owner = membership.role == OrgMemberRole::Owner
53 || role_keys.iter().any(|key| key == SYSTEM_ROLE_OWNER);
54 let is_admin = is_owner
55 || membership.role == OrgMemberRole::Admin
56 || role_keys.iter().any(|key| key == SYSTEM_ROLE_ADMIN);
57
58 let mut kernel = Kernel {
59 version,
60 is_owner,
61 is_admin,
62 role_keys,
63 ..Default::default()
64 };
65
66 if !role_ids.is_empty() {
67 let grants = organization_role_permissions::Entity::find()
68 .filter(organization_role_permissions::Column::RoleId.is_in(role_ids))
69 .all(db)
70 .await?;
71 for grant in grants {
72 if !is_known_permission(&grant.permission) {
73 continue;
74 }
75 match grant.effect {
76 PermissionEffect::Deny => push_unique(&mut kernel.role_deny, &grant.permission),
77 PermissionEffect::Allow => push_unique(&mut kernel.role_allow, &grant.permission),
78 }
79 }
80 }
81
82 let direct = organization_member_permissions::Entity::find()
83 .filter(organization_member_permissions::Column::MemberId.eq(membership.id))
84 .all(db)
85 .await?;
86 for grant in direct {
87 if !is_known_permission(&grant.permission) {
88 continue;
89 }
90 match grant.effect {
91 PermissionEffect::Deny => push_unique(&mut kernel.direct_deny, &grant.permission),
92 PermissionEffect::Allow => push_unique(&mut kernel.direct_allow, &grant.permission),
93 }
94 }
95
96 Ok(kernel)
97}
98
99fn decide(kernel: &Kernel, permission: &str) -> (&'static str, &'static str) {
100 if !is_known_permission(permission) {
101 return ("unknown_permission", "default_deny");
102 }
103 if kernel.is_owner {
104 return ("", "owner");
105 }
106 if kernel.is_admin && is_admin_permission(permission) {
107 return ("", "admin");
108 }
109 // 直挂层优先于角色层,层内 DENY 优先。两层都显式覆盖,
110 // 这样"角色给了、成员被单独收回"和"成员单独补授"都能表达。
111 if kernel.direct_deny.iter().any(|key| key == permission) {
112 return ("direct_deny", "direct");
113 }
114 if kernel.direct_allow.iter().any(|key| key == permission) {
115 return ("", "direct");
116 }
117 if kernel.role_deny.iter().any(|key| key == permission) {
118 return ("role_deny", "role");
119 }
120 if kernel.role_allow.iter().any(|key| key == permission) {
121 return ("", "role");
122 }
123 ("no_grant", "default_deny")
124}
125
126fn allowed_list(kernel: &Kernel) -> Vec<String> {
127 PERMISSIONS
128 .iter()
129 .filter(|permission| decide(kernel, permission).0.is_empty())
130 .map(|permission| (*permission).to_owned())
131 .collect()
132}
133
134async fn cached_kernel(
135 svc: &AuthSvc,
136 organization_id: Uuid,
137 user_id: i64,
138 version: i32,
139 membership: &organization_members::Model,
140) -> AuthResult<Kernel> {
141 let key = service::perm_cache_prefix(organization_id, user_id);
142 if let Ok(Some(bytes)) = svc.cache().get_bytes(&key).await
143 && let Ok(kernel) = serde_json::from_slice::<Kernel>(&bytes)
144 && kernel.version == version
145 {
146 return Ok(kernel);
147 }
148 let kernel = load_kernel(svc.db().read(), version, membership).await?;
149 if let Ok(bytes) = serde_json::to_vec(&kernel)
150 && let Err(error) = svc
151 .cache()
152 .set_ex(&key, bytes, Duration::from_secs(PERMISSION_CACHE_TTL_SECS))
153 .await
154 {
155 tracing::warn!(%error, %organization_id, user_id, "failed to cache the permission kernel");
156 }
157 Ok(kernel)
158}
159
160/// 前置闸门:组织状态 + 活跃会籍 + 判定核心。批量校验只走一次。
161enum Gate {
162 Denied {
163 version: i32,
164 reason: &'static str,
165 matched_by: &'static str,
166 },
167 Ready {
168 version: i32,
169 kernel: Kernel,
170 },
171}
172
173impl Gate {
174 fn version(&self) -> i32 {
175 match self {
176 Self::Denied { version, .. } | Self::Ready { version, .. } => *version,
177 }
178 }
179
180 fn resolution(&self, permission: &str) -> Resolution {
181 match self {
182 Self::Denied {
183 reason, matched_by, ..
184 } => Resolution {
185 allowed: false,
186 reason,
187 matched_by,
188 effective: Vec::new(),
189 },
190 Self::Ready { kernel, .. } => {
191 let (reason, matched_by) = decide(kernel, permission);
192 Resolution {
193 allowed: reason.is_empty(),
194 reason,
195 matched_by,
196 effective: allowed_list(kernel),
197 }
198 }
199 }
200 }
201}
202
203async fn gate(
204 svc: &AuthSvc,
205 organization_id: Uuid,
206 user_id: i64,
207 _request_id: Option<&str>,
208) -> AuthResult<Gate> {
209 let organization = svc.load_organization(organization_id).await?;
210 let version = organization.version;
211 if organization.status != crate::models::space::OrganizationStatus::Active {
212 return Ok(Gate::Denied {
213 version,
214 reason: "org_inactive",
215 matched_by: "org_status",
216 });
217 }
218 let membership = match member::find_any(svc.db().read(), organization_id, user_id).await? {
219 Some(row) => row,
220 None => {
221 return Ok(Gate::Denied {
222 version,
223 reason: "not_member",
224 matched_by: "membership",
225 });
226 }
227 };
228 if membership.banned_at.is_some() {
229 return Ok(Gate::Denied {
230 version,
231 reason: "member_banned",
232 matched_by: "membership",
233 });
234 }
235 if membership.left_at.is_some() {
236 return Ok(Gate::Denied {
237 version,
238 reason: "member_left",
239 matched_by: "membership",
240 });
241 }
242
243 let kernel = cached_kernel(svc, organization_id, user_id, version, &membership).await?;
244 Ok(Gate::Ready { version, kernel })
245}
246
247/// 唯一判定入口。顺序固定:组织状态 → 活跃会籍 → Owner → Admin 固有集
248/// → 角色 Allow/Deny → 成员直挂 Allow/Deny。
249#[tracing::instrument(skip_all, fields(organization_id = %organization_id, user_id = user_id, permission = %permission, request_id = %request_id.unwrap_or_default()))]
250pub(crate) async fn resolve(
251 svc: &AuthSvc,
252 organization_id: Uuid,
253 user_id: i64,
254 permission: &str,
255 request_id: Option<&str>,
256) -> AuthResult<Resolution> {
257 let gate = gate(svc, organization_id, user_id, request_id).await?;
258 Ok(gate.resolution(permission))
259}
260
261impl AuthSvc {
262 /// 平台管理员越过会籍判定,但仍受组织状态约束。
263 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
264 pub async fn check_permission(
265 &self,
266 actor_user_id: i64,
267 organization_id: Uuid,
268 user_id: i64,
269 permission: &str,
270 request_id: Option<&str>,
271 ) -> AuthResult<PermissionCheck> {
272 let target = if user_id == 0 { actor_user_id } else { user_id };
273 if target != actor_user_id && !self.is_platform_admin(actor_user_id).await? {
274 return Err(AuthError::Forbidden);
275 }
276 let gate = gate(self, organization_id, target, request_id).await?;
277 let resolution = gate.resolution(permission.trim());
278 Ok(PermissionCheck {
279 allowed: resolution.allowed,
280 reason: resolution.reason.to_owned(),
281 matched_by: resolution.matched_by.to_owned(),
282 organization_version: gate.version(),
283 })
284 }
285
286 /// 批量校验:组织与会籍只加载一次,权限逐个走同一条判定链。
287 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
288 pub async fn check_permissions(
289 &self,
290 actor_user_id: i64,
291 organization_id: Uuid,
292 user_id: i64,
293 permissions: &[String],
294 request_id: Option<&str>,
295 ) -> AuthResult<Vec<PermissionCheck>> {
296 let target = if user_id == 0 { actor_user_id } else { user_id };
297 if target != actor_user_id && !self.is_platform_admin(actor_user_id).await? {
298 return Err(AuthError::Forbidden);
299 }
300 let gate = gate(self, organization_id, target, request_id).await?;
301 let version = gate.version();
302 Ok(permissions
303 .iter()
304 .map(|permission| {
305 let resolution = gate.resolution(permission.trim());
306 PermissionCheck {
307 allowed: resolution.allowed,
308 reason: resolution.reason.to_owned(),
309 matched_by: resolution.matched_by.to_owned(),
310 organization_version: version,
311 }
312 })
313 .collect())
314 }
315}
316
317/// 列表展示用的有效权限集,复用同一条解析链。
318pub(crate) async fn effective<C: ConnectionTrait>(
319 db: &C,
320 membership: &organization_members::Model,
321) -> AuthResult<Vec<String>> {
322 let kernel = load_kernel(db, 0, membership).await?;
323 Ok(allowed_list(&kernel))
324}
Deleted lib/auth/src/handlers/identity/organization/role.rs
@@ -1,868 +0,0 @@
1use sea_orm::entity::Set;
2use sea_orm::sea_query::{Expr, ExprTrait};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter,
5 QueryOrder, QuerySelect, TransactionTrait,
6};
7use uuid::Uuid;
8
9use crate::AuthSvc;
10use crate::errors::{AuthError, AuthResult};
11use crate::events::label;
12use crate::handlers::identity::organization::service::{
13 bump_version, normalize_role_key, normalize_role_name,
14};
15use crate::handlers::identity::organization::types::{
16 CreateRoleBody, MemberSummary, PermissionGrant, PermissionGrant as Grant, RoleSummary,
17 SYSTEM_ROLE_ADMIN, SYSTEM_ROLE_MEMBER, SYSTEM_ROLE_OWNER, SetRolePermissionsBody,
18 is_known_permission,
19};
20use crate::handlers::identity::organization::{error, member};
21use crate::models::space::{
22 PermissionEffect, organization_member_roles, organization_role_permissions, organization_roles,
23};
24
25/// 内置角色的固有授权。OWNER 不需要显式授权(解析链直接短路),
26/// ADMIN 的固有集写在 permissions.rs 的白名单里,这里只落 MEMBER 的默认值。
27const MEMBER_DEFAULTS: &[&str] = &[
28 "org.view",
29 "member.view",
30 "role.view",
31 "space.view_space",
32 "space.read",
33 "space.message",
34];
35
36pub(crate) async fn seed_system_roles<C: ConnectionTrait>(
37 db: &C,
38 organization_id: Uuid,
39) -> AuthResult<Uuid> {
40 let owner = organization_roles::ActiveModel {
41 organization_id: Set(organization_id),
42 key: Set(SYSTEM_ROLE_OWNER.to_owned()),
43 name: Set("Owner".to_owned()),
44 description: Set(Some("full control of the organization".to_owned())),
45 rank: Set(100),
46 system: Set(true),
47 default_role: Set(false),
48 ..Default::default()
49 }
50 .insert(db)
51 .await?;
52 let admin = organization_roles::ActiveModel {
53 organization_id: Set(organization_id),
54 key: Set(SYSTEM_ROLE_ADMIN.to_owned()),
55 name: Set("Admin".to_owned()),
56 description: Set(Some("manages members, roles and invites".to_owned())),
57 rank: Set(50),
58 system: Set(true),
59 default_role: Set(false),
60 ..Default::default()
61 }
62 .insert(db)
63 .await?;
64 let member = organization_roles::ActiveModel {
65 organization_id: Set(organization_id),
66 key: Set(SYSTEM_ROLE_MEMBER.to_owned()),
67 name: Set("Member".to_owned()),
68 description: Set(Some("default role for every new member".to_owned())),
69 rank: Set(0),
70 system: Set(true),
71 default_role: Set(true),
72 ..Default::default()
73 }
74 .insert(db)
75 .await?;
76
77 for permission in MEMBER_DEFAULTS {
78 organization_role_permissions::ActiveModel {
79 role_id: Set(member.id),
80 permission: Set((*permission).to_owned()),
81 effect: Set(PermissionEffect::Allow),
82 ..Default::default()
83 }
84 .insert(db)
85 .await?;
86 }
87 let _ = admin;
88 Ok(owner.id)
89}
90
91pub(crate) async fn find_by_key<C: ConnectionTrait>(
92 organization_id: Uuid,
93 key: &str,
94 db: &C,
95) -> AuthResult<Option<organization_roles::Model>> {
96 Ok(organization_roles::Entity::find()
97 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
98 .filter(organization_roles::Column::Key.eq(key))
99 .one(db)
100 .await?)
101}
102
103pub(crate) async fn keys_of_ids<C: ConnectionTrait>(
104 db: &C,
105 role_ids: &[Uuid],
106) -> AuthResult<Vec<String>> {
107 if role_ids.is_empty() {
108 return Ok(Vec::new());
109 }
110 Ok(organization_roles::Entity::find()
111 .select_only()
112 .column(organization_roles::Column::Key)
113 .filter(organization_roles::Column::Id.is_in(role_ids.to_vec()))
114 .order_by_asc(organization_roles::Column::Rank)
115 .into_tuple::<String>()
116 .all(db)
117 .await?)
118}
119
120pub(crate) async fn role_keys_of<C: ConnectionTrait>(
121 db: &C,
122 member_id: Uuid,
123) -> AuthResult<Vec<String>> {
124 let ids = organization_member_roles::Entity::find()
125 .select_only()
126 .column(organization_member_roles::Column::RoleId)
127 .filter(organization_member_roles::Column::MemberId.eq(member_id))
128 .into_tuple::<Uuid>()
129 .all(db)
130 .await?;
131 keys_of_ids(db, &ids).await
132}
133
134pub(crate) async fn grants_of<C: ConnectionTrait>(
135 db: &C,
136 role_id: Uuid,
137) -> AuthResult<Vec<PermissionGrant>> {
138 let rows = organization_role_permissions::Entity::find()
139 .filter(organization_role_permissions::Column::RoleId.eq(role_id))
140 .order_by_asc(organization_role_permissions::Column::Permission)
141 .all(db)
142 .await?;
143 Ok(rows
144 .into_iter()
145 .map(|row| Grant {
146 permission: row.permission,
147 effect: row.effect,
148 })
149 .collect())
150}
151
152/// 绑定幂等:唯一键冲突直接吞掉,重复授予不该报错。
153pub(crate) async fn bind_role<C: ConnectionTrait>(
154 db: &C,
155 member_id: Uuid,
156 role_id: Uuid,
157 granted_by: i64,
158) -> AuthResult<()> {
159 let existing = organization_member_roles::Entity::find()
160 .filter(organization_member_roles::Column::MemberId.eq(member_id))
161 .filter(organization_member_roles::Column::RoleId.eq(role_id))
162 .one(db)
163 .await?;
164 if existing.is_some() {
165 return Ok(());
166 }
167 organization_member_roles::ActiveModel {
168 member_id: Set(member_id),
169 role_id: Set(role_id),
170 granted_by: Set(Some(granted_by)),
171 ..Default::default()
172 }
173 .insert(db)
174 .await?;
175 Ok(())
176}
177
178pub(crate) async fn unbind_role<C: ConnectionTrait>(
179 db: &C,
180 member_id: Uuid,
181 role_id: Uuid,
182) -> AuthResult<u64> {
183 let result = organization_member_roles::Entity::delete_many()
184 .filter(organization_member_roles::Column::MemberId.eq(member_id))
185 .filter(organization_member_roles::Column::RoleId.eq(role_id))
186 .exec(db)
187 .await?;
188 Ok(result.rows_affected)
189}
190
191pub(crate) async fn clear_bindings<C: ConnectionTrait>(db: &C, member_id: Uuid) -> AuthResult<()> {
192 organization_member_roles::Entity::delete_many()
193 .filter(organization_member_roles::Column::MemberId.eq(member_id))
194 .exec(db)
195 .await?;
196 Ok(())
197}
198
199pub(crate) async fn load_keys<C: ConnectionTrait>(
200 organization_id: Uuid,
201 keys: &[String],
202 db: &C,
203) -> AuthResult<Vec<Uuid>> {
204 if keys.is_empty() {
205 return Ok(Vec::new());
206 }
207 let mut out = Vec::with_capacity(keys.len());
208 for key in keys {
209 let row = find_by_key(organization_id, key.trim(), db)
210 .await?
211 .ok_or(AuthError::OrganizationRoleNotFound)?;
212 if row.key == SYSTEM_ROLE_OWNER {
213 return Err(AuthError::Forbidden);
214 }
215 out.push(row.id);
216 }
217 Ok(out)
218}
219
220async fn replace_grants<C: ConnectionTrait>(
221 db: &C,
222 role_id: Uuid,
223 org_version_holder: &Uuid,
224 grants: &[PermissionGrant],
225) -> AuthResult<()> {
226 organization_role_permissions::Entity::delete_many()
227 .filter(organization_role_permissions::Column::RoleId.eq(role_id))
228 .exec(db)
229 .await?;
230 let _ = org_version_holder;
231 for grant in grants {
232 organization_role_permissions::ActiveModel {
233 role_id: Set(role_id),
234 permission: Set(grant.permission.clone()),
235 effect: Set(grant.effect),
236 ..Default::default()
237 }
238 .insert(db)
239 .await?;
240 }
241 Ok(())
242}
243
244impl AuthSvc {
245 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
246 pub async fn list_roles(
247 &self,
248 actor_user_id: i64,
249 organization_id: Uuid,
250 request_id: Option<&str>,
251 ) -> AuthResult<Vec<RoleSummary>> {
252 self.require_permission(actor_user_id, organization_id, "role.view", request_id)
253 .await?;
254 let rows = organization_roles::Entity::find()
255 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
256 .order_by_desc(organization_roles::Column::Rank)
257 .order_by_asc(organization_roles::Column::Key)
258 .all(self.db().read())
259 .await?;
260 let mut out = Vec::with_capacity(rows.len());
261 for row in rows {
262 out.push(self.role_summary(&row).await?);
263 }
264 Ok(out)
265 }
266
267 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
268 pub async fn create_role(
269 &self,
270 actor_user_id: i64,
271 organization_id: Uuid,
272 body: CreateRoleBody,
273 request_id: Option<&str>,
274 ) -> AuthResult<RoleSummary> {
275 self.require_permission(actor_user_id, organization_id, "role.create", request_id)
276 .await?;
277 let key = normalize_role_key(&body.key)?;
278 let name = normalize_role_name(&body.name)?;
279 let grants = Self::validate_grants(&body.permissions)?;
280 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
281 .await?;
282 if find_by_key(organization_id, &key, self.db().read())
283 .await?
284 .is_some()
285 {
286 return Err(AuthError::OrganizationRoleKeyTaken);
287 }
288
289 let txn = self.db().write().begin().await?;
290 let role = organization_roles::ActiveModel {
291 organization_id: Set(organization_id),
292 key: Set(key.clone()),
293 name: Set(name.clone()),
294 description: Set(body.description.clone()),
295 rank: Set(body.rank),
296 system: Set(false),
297 default_role: Set(body.default_role),
298 self_assignable: Set(body.self_assignable),
299 ..Default::default()
300 }
301 .insert(&txn)
302 .await
303 .map_err(error::map_insert_conflict)?;
304 replace_grants(&txn, role.id, &organization_id, &grants).await?;
305 let version = bump_version(&txn, organization_id).await?;
306 let refreshed = load_role(&txn, organization_id, role.id).await?;
307 self.emit_role(
308 &txn,
309 organization_id,
310 actor_user_id,
311 serde_json::json!({
312 "id": role.id,
313 "key": &key,
314 "action": "create",
315 "request_id": request_id,
316 }),
317 request_id,
318 )
319 .await?;
320 txn.commit().await?;
321
322 self.invalidate_org(organization_id).await?;
323 let mut summary = self.role_summary(&refreshed).await?;
324 summary.version = version;
325 Ok(summary)
326 }
327
328 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, role_id = %role_id, request_id = %request_id.unwrap_or_default()))]
329 pub async fn set_role_permissions(
330 &self,
331 actor_user_id: i64,
332 organization_id: Uuid,
333 role_id: Uuid,
334 body: SetRolePermissionsBody,
335 request_id: Option<&str>,
336 ) -> AuthResult<RoleSummary> {
337 self.require_permission(
338 actor_user_id,
339 organization_id,
340 "role.permission.update",
341 request_id,
342 )
343 .await?;
344 let role = load_role(self.db().read(), organization_id, role_id).await?;
345 if role.key == SYSTEM_ROLE_OWNER {
346 return Err(AuthError::OrganizationRoleSystem);
347 }
348 let grants = Self::validate_grants(&body.permissions)?;
349 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
350 .await?;
351
352 let txn = self.db().write().begin().await?;
353 replace_grants(&txn, role_id, &organization_id, &grants).await?;
354 organization_roles::Entity::update_many()
355 .col_expr(
356 organization_roles::Column::Version,
357 Expr::col(organization_roles::Column::Version).add(1),
358 )
359 .col_expr(
360 organization_roles::Column::UpdatedAt,
361 Expr::value(chrono::Utc::now()),
362 )
363 .filter(organization_roles::Column::Id.eq(role_id))
364 .exec(&txn)
365 .await?;
366 let version = bump_version(&txn, organization_id).await?;
367 let refreshed = load_role(&txn, organization_id, role_id).await?;
368 self.emit_role(
369 &txn,
370 organization_id,
371 actor_user_id,
372 serde_json::json!({
373 "id": role_id,
374 "key": &refreshed.key,
375 "action": "set_permissions",
376 "permissions": body.permissions.iter().map(|g| &g.permission).collect::<Vec<_>>(),
377 "request_id": request_id,
378 }),
379 request_id,
380 )
381 .await?;
382 txn.commit().await?;
383
384 self.invalidate_org(organization_id).await?;
385 let mut summary = self.role_summary(&refreshed).await?;
386 summary.version = version;
387 Ok(summary)
388 }
389
390 #[allow(clippy::too_many_arguments)]
391 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, role_id = %role_id, request_id = %request_id.unwrap_or_default()))]
392 pub async fn update_role(
393 &self,
394 actor_user_id: i64,
395 organization_id: Uuid,
396 role_id: Uuid,
397 name: Option<String>,
398 description: Option<String>,
399 rank: Option<i32>,
400 default_role: Option<bool>,
401 self_assignable: Option<bool>,
402 expected_version: i32,
403 request_id: Option<&str>,
404 ) -> AuthResult<RoleSummary> {
405 self.require_permission(actor_user_id, organization_id, "role.update", request_id)
406 .await?;
407 let role = load_role(self.db().read(), organization_id, role_id).await?;
408 let name = name.as_deref().map(normalize_role_name).transpose()?;
409 if expected_version > 0 && role.version != expected_version {
410 return Err(AuthError::VersionMismatch {
411 current: role.version,
412 });
413 }
414 if role.key == SYSTEM_ROLE_OWNER
415 && (rank.is_some() || default_role.is_some() || self_assignable.is_some())
416 {
417 return Err(AuthError::OrganizationRoleSystem);
418 }
419 // 系统角色的排名、默认身份、自助加入都是结构属性,任何改法都会改变权限基线。
420 if role.system && (rank.is_some() || default_role.is_some() || self_assignable.is_some()) {
421 return Err(AuthError::OrganizationRoleSystem);
422 }
423
424 let txn = self.db().write().begin().await?;
425 if let Some(true) = default_role {
426 // 默认角色唯一:换默认之前先清掉旧的,否则新成员会被绑两个默认角色。
427 organization_roles::Entity::update_many()
428 .col_expr(organization_roles::Column::DefaultRole, Expr::value(false))
429 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
430 .exec(&txn)
431 .await?;
432 }
433 let mut active: organization_roles::ActiveModel = role.clone().into();
434 if let Some(value) = name {
435 active.name = Set(value);
436 }
437 if let Some(value) = description {
438 active.description = Set(if value.trim().is_empty() {
439 None
440 } else {
441 Some(value)
442 });
443 }
444 if let Some(value) = rank {
445 active.rank = Set(value);
446 }
447 if let Some(value) = default_role {
448 active.default_role = Set(value);
449 }
450 if let Some(value) = self_assignable {
451 if role.system {
452 return Err(AuthError::OrganizationRoleSystem);
453 }
454 active.self_assignable = Set(value);
455 }
456 active.update(&txn).await?;
457 let version = bump_version(&txn, organization_id).await?;
458 let refreshed = load_role(&txn, organization_id, role_id).await?;
459 self.emit_role(
460 &txn,
461 organization_id,
462 actor_user_id,
463 serde_json::json!({
464 "id": role_id,
465 "key": &role.key,
466 "action": "update",
467 "request_id": request_id,
468 }),
469 request_id,
470 )
471 .await?;
472 txn.commit().await?;
473
474 self.invalidate_org(organization_id).await?;
475 let mut summary = self.role_summary(&refreshed).await?;
476 summary.version = version;
477 Ok(summary)
478 }
479
480 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, role_id = %role_id, request_id = %request_id.unwrap_or_default()))]
481 pub async fn delete_role(
482 &self,
483 actor_user_id: i64,
484 organization_id: Uuid,
485 role_id: Uuid,
486 request_id: Option<&str>,
487 ) -> AuthResult<bool> {
488 self.require_permission(actor_user_id, organization_id, "role.delete", request_id)
489 .await?;
490 let role = load_role(self.db().read(), organization_id, role_id).await?;
491 if role.system {
492 return Err(AuthError::OrganizationRoleSystem);
493 }
494 let bound = organization_member_roles::Entity::find()
495 .filter(organization_member_roles::Column::RoleId.eq(role_id))
496 .count(self.db().read())
497 .await?;
498 if bound > 0 {
499 return Err(AuthError::OrganizationRoleInUse);
500 }
501
502 let txn = self.db().write().begin().await?;
503 organization_role_permissions::Entity::delete_many()
504 .filter(organization_role_permissions::Column::RoleId.eq(role_id))
505 .exec(&txn)
506 .await?;
507 organization_roles::Entity::delete_many()
508 .filter(organization_roles::Column::Id.eq(role_id))
509 .exec(&txn)
510 .await?;
511 let version = bump_version(&txn, organization_id).await?;
512 self.emit_role(
513 &txn,
514 organization_id,
515 actor_user_id,
516 serde_json::json!({
517 "id": role_id,
518 "key": &role.key,
519 "action": "delete",
520 "version": version,
521 "request_id": request_id,
522 }),
523 request_id,
524 )
525 .await?;
526 txn.commit().await?;
527
528 self.invalidate_org(organization_id).await?;
529 Ok(true)
530 }
531
532 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, member_id = %member_id, role_key = %role_key, request_id = %request_id.unwrap_or_default()))]
533 pub async fn assign_role(
534 &self,
535 actor_user_id: i64,
536 organization_id: Uuid,
537 member_id: Uuid,
538 role_key: &str,
539 request_id: Option<&str>,
540 ) -> AuthResult<Vec<String>> {
541 let member = crate::handlers::identity::organization::member::load_by_id(
542 self.db().read(),
543 organization_id,
544 member_id,
545 )
546 .await?;
547 if member.banned_at.is_some() || member.left_at.is_some() {
548 return Err(AuthError::OrganizationMemberNotFound);
549 }
550 let role = find_by_key(organization_id, role_key.trim(), self.db().read())
551 .await?
552 .ok_or(AuthError::OrganizationRoleNotFound)?;
553 if role.key == SYSTEM_ROLE_OWNER {
554 return Err(AuthError::Forbidden);
555 }
556 if actor_user_id == member.user_id {
557 if role.system || !role.self_assignable {
558 return Err(AuthError::Forbidden);
559 }
560 } else {
561 self.require_permission(actor_user_id, organization_id, "role.assign", request_id)
562 .await?;
563 // 系统角色等同于整套权限包:代绑时必须具备其中的每一条权限。
564 if role.system {
565 let grants = grants_of(self.db().read(), role.id).await?;
566 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
567 .await?;
568 }
569 }
570
571 let txn = self.db().write().begin().await?;
572 bind_role(&txn, member_id, role.id, actor_user_id).await?;
573 let version = bump_version(&txn, organization_id).await?;
574 let keys = role_keys_of(&txn, member_id).await?;
575 self.emit_role(
576 &txn,
577 organization_id,
578 actor_user_id,
579 serde_json::json!({
580 "member_id": member_id,
581 "role_key": &role.key,
582 "action": "assign",
583 "version": version,
584 "request_id": request_id,
585 }),
586 request_id,
587 )
588 .await?;
589 txn.commit().await?;
590
591 self.invalidate_org(organization_id).await?;
592 Ok(keys)
593 }
594
595 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, member_id = %member_id, role_key = %role_key, request_id = %request_id.unwrap_or_default()))]
596 pub async fn unassign_role(
597 &self,
598 actor_user_id: i64,
599 organization_id: Uuid,
600 member_id: Uuid,
601 role_key: &str,
602 request_id: Option<&str>,
603 ) -> AuthResult<Vec<String>> {
604 let role = find_by_key(organization_id, role_key.trim(), self.db().read())
605 .await?
606 .ok_or(AuthError::OrganizationRoleNotFound)?;
607 if role.key == SYSTEM_ROLE_OWNER {
608 return Err(AuthError::OrganizationRoleSystem);
609 }
610 let member = crate::handlers::identity::organization::member::load_by_id(
611 self.db().read(),
612 organization_id,
613 member_id,
614 )
615 .await?;
616 if actor_user_id == member.user_id {
617 if !role.self_assignable {
618 return Err(AuthError::Forbidden);
619 }
620 } else {
621 // 解绑系统角色必须与绑定同权:否则 Admin 绑定后没人能收回。
622 self.require_permission(actor_user_id, organization_id, "role.assign", request_id)
623 .await?;
624 if role.system {
625 let grants = grants_of(self.db().read(), role.id).await?;
626 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
627 .await?;
628 }
629 }
630
631 let txn = self.db().write().begin().await?;
632 unbind_role(&txn, member_id, role.id).await?;
633 let version = bump_version(&txn, organization_id).await?;
634 let keys = role_keys_of(&txn, member_id).await?;
635 self.emit_role(
636 &txn,
637 organization_id,
638 actor_user_id,
639 serde_json::json!({
640 "member_id": member.id,
641 "role_key": &role.key,
642 "action": "unassign",
643 "version": version,
644 "request_id": request_id,
645 }),
646 request_id,
647 )
648 .await?;
649 txn.commit().await?;
650
651 self.invalidate_org(organization_id).await?;
652 Ok(keys)
653 }
654
655 /// 成员直挂权限:整表替换语义。`permission` 必须在白名单内,否则直接拒绝。
656 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, member_id = %member_id, request_id = %request_id.unwrap_or_default()))]
657 pub async fn set_member_permissions(
658 &self,
659 actor_user_id: i64,
660 organization_id: Uuid,
661 member_id: Uuid,
662 grants: Vec<PermissionGrant>,
663 request_id: Option<&str>,
664 ) -> AuthResult<Vec<String>> {
665 self.require_permission(
666 actor_user_id,
667 organization_id,
668 "role.permission.update",
669 request_id,
670 )
671 .await?;
672 let member = crate::handlers::identity::organization::member::load_by_id(
673 self.db().read(),
674 organization_id,
675 member_id,
676 )
677 .await?;
678 if member.role == crate::models::space::OrgMemberRole::Owner {
679 return Err(AuthError::OrganizationOwnerRequired);
680 }
681 let grants = Self::validate_grants(&grants)?;
682 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
683 .await?;
684
685 let txn = self.db().write().begin().await?;
686 crate::models::space::organization_member_permissions::Entity::delete_many()
687 .filter(
688 crate::models::space::organization_member_permissions::Column::MemberId
689 .eq(member_id),
690 )
691 .exec(&txn)
692 .await?;
693 for grant in &grants {
694 crate::models::space::organization_member_permissions::ActiveModel {
695 member_id: Set(member_id),
696 permission: Set(grant.permission.clone()),
697 effect: Set(grant.effect),
698 granted_by: Set(Some(actor_user_id)),
699 ..Default::default()
700 }
701 .insert(&txn)
702 .await?;
703 }
704 let version = bump_version(&txn, organization_id).await?;
705 self.emit_role(
706 &txn,
707 organization_id,
708 actor_user_id,
709 serde_json::json!({
710 "member_id": member_id,
711 "granted": grants.len(),
712 "version": version,
713 "request_id": request_id,
714 }),
715 request_id,
716 )
717 .await?;
718 txn.commit().await?;
719
720 self.invalidate_org(organization_id).await?;
721 self.member_permissions(member_id).await
722 }
723
724 pub(crate) async fn member_permissions(&self, member_id: Uuid) -> AuthResult<Vec<String>> {
725 let rows = crate::models::space::organization_member_permissions::Entity::find()
726 .filter(
727 crate::models::space::organization_member_permissions::Column::MemberId
728 .eq(member_id),
729 )
730 .order_by_asc(crate::models::space::organization_member_permissions::Column::Permission)
731 .all(self.db().read())
732 .await?;
733 Ok(rows
734 .into_iter()
735 .filter(|row| is_known_permission(&row.permission))
736 .map(|row| row.permission)
737 .collect())
738 }
739
740 /// 角色事件与业务写入同事务入队:提交时一起可见,回滚时一起消失。
741 pub(crate) async fn emit_role(
742 &self,
743 txn: &sea_orm::DatabaseTransaction,
744 organization_id: Uuid,
745 actor_user_id: i64,
746 payload: serde_json::Value,
747 _request_id: Option<&str>,
748 ) -> AuthResult<()> {
749 let labels = crate::events::Labels::new()
750 .with(label::ORGANIZATION_ID, organization_id)
751 .with(label::ACTOR_USER_ID, actor_user_id);
752 self.emit_tx(txn, label::ORGANIZATION_ROLE_UPDATE, labels, payload, None)
753 .await
754 }
755
756 /// 单个角色详情。
757 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, role_id = %role_id, request_id = %request_id.unwrap_or_default()))]
758 pub async fn get_role(
759 &self,
760 actor_user_id: i64,
761 organization_id: Uuid,
762 role_id: Uuid,
763 request_id: Option<&str>,
764 ) -> AuthResult<RoleSummary> {
765 self.require_permission(actor_user_id, organization_id, "role.view", request_id)
766 .await?;
767 let row = load_role(self.db().read(), organization_id, role_id).await?;
768 self.role_summary(&row).await
769 }
770
771 /// 自助加入:只接受 `self_assignable = true` 的非内置角色。
772 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, role_id = %role_id, request_id = %request_id.unwrap_or_default()))]
773 pub async fn join_role(
774 &self,
775 actor_user_id: i64,
776 organization_id: Uuid,
777 role_id: Uuid,
778 request_id: Option<&str>,
779 ) -> AuthResult<MemberSummary> {
780 let role = load_role(self.db().read(), organization_id, role_id).await?;
781 if role.system || !role.self_assignable {
782 return Err(AuthError::Forbidden);
783 }
784 let row = member::find_active(self.db().read(), organization_id, actor_user_id)
785 .await?
786 .ok_or(AuthError::Forbidden)?;
787
788 let txn = self.db().write().begin().await?;
789 bind_role(&txn, row.id, role_id, actor_user_id).await?;
790 let version = bump_version(&txn, organization_id).await?;
791 let refreshed = member::load_by_id(&txn, organization_id, row.id).await?;
792 self.emit_member(
793 &txn,
794 organization_id,
795 actor_user_id,
796 serde_json::json!({
797 "id": row.id,
798 "user_id": actor_user_id,
799 "action": "join_role",
800 "role_id": role_id,
801 "request_id": request_id,
802 }),
803 request_id,
804 )
805 .await?;
806 txn.commit().await?;
807
808 self.invalidate_org(organization_id).await?;
809 let mut summary = self.member_summary(&refreshed).await?;
810 summary.version = version;
811 Ok(summary)
812 }
813
814 /// 自助退出:同样只接受自助角色;重复退出视为成功。
815 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, role_id = %role_id, request_id = %request_id.unwrap_or_default()))]
816 pub async fn leave_role(
817 &self,
818 actor_user_id: i64,
819 organization_id: Uuid,
820 role_id: Uuid,
821 request_id: Option<&str>,
822 ) -> AuthResult<MemberSummary> {
823 let role = load_role(self.db().read(), organization_id, role_id).await?;
824 if role.system || !role.self_assignable {
825 return Err(AuthError::Forbidden);
826 }
827 let row = member::find_active(self.db().read(), organization_id, actor_user_id)
828 .await?
829 .ok_or(AuthError::Forbidden)?;
830
831 let txn = self.db().write().begin().await?;
832 unbind_role(&txn, row.id, role_id).await?;
833 let version = bump_version(&txn, organization_id).await?;
834 let refreshed = member::load_by_id(&txn, organization_id, row.id).await?;
835 self.emit_member(
836 &txn,
837 organization_id,
838 actor_user_id,
839 serde_json::json!({
840 "id": row.id,
841 "user_id": actor_user_id,
842 "action": "leave_role",
843 "role_id": role_id,
844 "request_id": request_id,
845 }),
846 request_id,
847 )
848 .await?;
849 txn.commit().await?;
850
851 self.invalidate_org(organization_id).await?;
852 let mut summary = self.member_summary(&refreshed).await?;
853 summary.version = version;
854 Ok(summary)
855 }
856}
857
858async fn load_role<C: ConnectionTrait>(
859 db: &C,
860 organization_id: Uuid,
861 role_id: Uuid,
862) -> AuthResult<organization_roles::Model> {
863 organization_roles::Entity::find_by_id(role_id)
864 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
865 .one(db)
866 .await?
867 .ok_or(AuthError::OrganizationRoleNotFound)
868}
Deleted lib/auth/src/handlers/identity/organization/service.rs
@@ -1,1036 +0,0 @@
1use chrono::Utc;
2use sea_orm::entity::Set;
3use sea_orm::sea_query::{Expr, ExprTrait};
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
6 QuerySelect, TransactionTrait,
7};
8use uuid::Uuid;
9
10use crate::AuthSvc;
11use crate::errors::{AuthError, AuthResult};
12use crate::events::{Labels, label};
13use crate::handlers::identity::organization::types::{
14 CreateOrganizationBody, MAX_ORG_DESCRIPTION_LEN, MAX_ORG_NAME_LEN, MAX_PERMISSION_LEN,
15 MAX_ROLE_KEY_LEN, MAX_ROLE_NAME_LEN, MAX_SLUG_LEN, MemberSummary, OrganizationSummary,
16 PermissionGrant, RoleSummary, SYSTEM_ROLE_ADMIN, SYSTEM_ROLE_OWNER, UpdateOrganizationBody,
17 is_known_permission,
18};
19use crate::handlers::identity::organization::{error, member, permission, role};
20use crate::models::space::{
21 OrgMemberRole, OrganizationStatus, PermissionEffect, organization_invite_redemptions,
22 organization_invites, organization_member_permissions, organization_member_roles,
23 organization_members, organization_role_permissions, organization_roles, organizations,
24};
25use crate::models::users::users;
26
27pub(crate) fn perm_cache_prefix(organization_id: Uuid, user_id: i64) -> String {
28 format!("auth:org:perm:{organization_id}:{user_id}")
29}
30
31impl AuthSvc {
32 /// 组织写操作的统一收尾:推进 version,然后清掉本组织全部派生缓存。
33 /// version 同时是权限缓存的版本键与乐观锁基准,必须与写操作同事务推进。
34 pub(crate) async fn invalidate_org(&self, organization_id: Uuid) -> AuthResult<()> {
35 let members = organization_members::Entity::find()
36 .select_only()
37 .column(organization_members::Column::UserId)
38 .filter(organization_members::Column::OrganizationId.eq(organization_id))
39 .into_tuple::<i64>()
40 .all(self.db().read())
41 .await?;
42 self.drop_org_cache(organization_id, members).await
43 }
44
45 /// 成员行已随事务删除时用这一版:user_id 由调用方在事务内收集后传入。
46 pub(crate) async fn drop_org_cache(
47 &self,
48 organization_id: Uuid,
49 members: Vec<i64>,
50 ) -> AuthResult<()> {
51 let cache = self.cache();
52 for key in [
53 format!("auth:org:{organization_id}"),
54 format!("auth:org:summary:{organization_id}"),
55 ] {
56 if let Err(error) = cache.delete(&key).await {
57 tracing::warn!(%error, %organization_id, "failed to drop the organization cache");
58 }
59 }
60 for user_id in members {
61 if let Err(error) = cache
62 .delete(&perm_cache_prefix(organization_id, user_id))
63 .await
64 {
65 tracing::warn!(%error, %organization_id, user_id, "failed to drop the permission cache");
66 }
67 }
68 Ok(())
69 }
70
71 pub(crate) async fn peek_org_version(&self, organization_id: Uuid) -> AuthResult<Option<i32>> {
72 Ok(organizations::Entity::find_by_id(organization_id)
73 .select_only()
74 .column(organizations::Column::Version)
75 .into_tuple::<i32>()
76 .one(self.db().read())
77 .await?)
78 }
79
80 /// `expected_version <= 0` 表示调用方不做乐观锁校验。
81 pub(crate) async fn check_version(
82 &self,
83 organization_id: Uuid,
84 expected: i32,
85 ) -> AuthResult<()> {
86 if expected <= 0 {
87 return Ok(());
88 }
89 let current = self
90 .peek_org_version(organization_id)
91 .await?
92 .ok_or(AuthError::OrganizationNotFound)?;
93 if current != expected {
94 return Err(AuthError::VersionMismatch { current });
95 }
96 Ok(())
97 }
98
99 pub(crate) async fn is_platform_admin(&self, user_id: i64) -> AuthResult<bool> {
100 Ok(users::Entity::find_by_id(user_id)
101 .select_only()
102 .column(users::Column::IsAdmin)
103 .into_tuple::<bool>()
104 .one(self.db().read())
105 .await?
106 .unwrap_or(false))
107 }
108
109 /// 唯一权限闸门。所有组织内写操作先过这里,语义与 CheckPermission 完全一致。
110 pub(crate) async fn require_permission(
111 &self,
112 user_id: i64,
113 organization_id: Uuid,
114 permission: &str,
115 request_id: Option<&str>,
116 ) -> AuthResult<()> {
117 let outcome =
118 permission::resolve(self, organization_id, user_id, permission, request_id).await?;
119 if outcome.allowed {
120 return Ok(());
121 }
122 Err(error::reason_error(outcome.reason, permission))
123 }
124
125 /// 授权上限:只能授出自己当前确实持有的权限,不能借新建/改角色提权。
126 pub(crate) async fn require_grantable(
127 &self,
128 user_id: i64,
129 organization_id: Uuid,
130 grants: &[PermissionGrant],
131 request_id: Option<&str>,
132 ) -> AuthResult<()> {
133 for grant in grants {
134 if grant.effect == PermissionEffect::Allow {
135 self.require_permission(user_id, organization_id, &grant.permission, request_id)
136 .await?;
137 }
138 }
139 Ok(())
140 }
141
142 /// 组织事件在业务事务**内部**入队:业务写入与事件行同生共死。
143 ///
144 /// 写不进 outbox 就让整个事务回滚(`AuthError` 向上传播),这是刻意选择:
145 /// 宁可这次写操作失败重试,也不要出现「组织改成了、消费方永远不知道」。
146 async fn emit_org<T: serde::Serialize>(
147 &self,
148 txn: &sea_orm::DatabaseTransaction,
149 kind: &str,
150 organization_id: Uuid,
151 actor_user_id: i64,
152 payload: T,
153 request_id: Option<&str>,
154 ) -> AuthResult<()> {
155 let labels = Labels::new()
156 .with(label::ORGANIZATION_ID, organization_id)
157 .with(label::ACTOR_USER_ID, actor_user_id);
158 self.emit_tx(txn, kind, labels, payload, request_id).await
159 }
160
161 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, request_id = %request_id.unwrap_or_default()))]
162 pub async fn create_organization(
163 &self,
164 actor_user_id: i64,
165 body: CreateOrganizationBody,
166 request_id: Option<&str>,
167 ) -> AuthResult<OrganizationSummary> {
168 let slug = normalize_slug(&body.slug)?;
169 let name = normalize_name(&body.name)?;
170 let description = normalize_description(body.description.as_deref())?;
171 let avatar_url = normalize_avatar(body.avatar_url.as_deref())?;
172
173 users::Entity::find_by_id(actor_user_id)
174 .filter(users::Column::DeletedAt.is_null())
175 .one(self.db().read())
176 .await?
177 .ok_or(AuthError::UserNotFound)?;
178 if organizations::Entity::find()
179 .filter(organizations::Column::Slug.eq(slug.clone()))
180 .one(self.db().read())
181 .await?
182 .is_some()
183 {
184 return Err(AuthError::OrganizationSlugTaken);
185 }
186
187 let welcome_message = match body.welcome_message.as_deref() {
188 Some(value) if value.trim().is_empty() => None,
189 Some(value) => normalize_description(Some(value))?,
190 None => None,
191 };
192 let allow_join_level = valid_join_level(body.allow_join_level.unwrap_or(0))?;
193
194 let txn = self.db().write().begin().await?;
195 let organization = organizations::ActiveModel {
196 slug: Set(slug),
197 name: Set(name),
198 description: Set(description),
199 avatar_url: Set(avatar_url),
200 status: Set(OrganizationStatus::Active),
201 owner_user_id: Set(actor_user_id),
202 join_policy: Set(body
203 .join_policy
204 .unwrap_or(crate::models::space::OrganizationJoinPolicy::InviteOnly)),
205 allow_join: Set(body.allow_join.unwrap_or(false)),
206 require_email_verified: Set(body.require_email_verified.unwrap_or(true)),
207 require_mfa: Set(body.require_mfa.unwrap_or(false)),
208 welcome_message: Set(welcome_message),
209 member_must_enable_2fa: Set(body.member_must_enable_2fa.unwrap_or(false)),
210 allow_invite: Set(body.allow_invite.unwrap_or(true)),
211 allow_join_level: Set(allow_join_level),
212 ..Default::default()
213 }
214 .insert(&txn)
215 .await
216 .map_err(error::map_insert_conflict)?;
217
218 let owner_role = role::seed_system_roles(&txn, organization.id).await?;
219 let membership = member::insert_owner(&txn, organization.id, actor_user_id).await?;
220 role::bind_role(&txn, membership.id, owner_role, actor_user_id).await?;
221 self.emit_org(
222 &txn,
223 label::ORGANIZATION_LIFE_CYCLE_CREATE,
224 organization.id,
225 actor_user_id,
226 serde_json::json!({
227 "id": organization.id,
228 "slug": &organization.slug,
229 "name": &organization.name,
230 "request_id": request_id,
231 }),
232 request_id,
233 )
234 .await?;
235 txn.commit().await?;
236
237 let summary = self
238 .organization_summary(&organization, Some(actor_user_id))
239 .await?;
240 Ok(summary)
241 }
242
243 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = ?organization_id, request_id = %request_id.unwrap_or_default()))]
244 pub async fn get_organization(
245 &self,
246 actor_user_id: i64,
247 organization_id: Option<Uuid>,
248 slug: Option<&str>,
249 request_id: Option<&str>,
250 ) -> AuthResult<(OrganizationSummary, Option<MemberSummary>)> {
251 let organization = match (
252 organization_id,
253 slug.map(str::trim).filter(|value| !value.is_empty()),
254 ) {
255 (Some(id), _) => {
256 organizations::Entity::find_by_id(id)
257 .filter(organizations::Column::DeletedAt.is_null())
258 .one(self.db().read())
259 .await?
260 }
261 (None, Some(slug)) => {
262 organizations::Entity::find()
263 .filter(organizations::Column::Slug.eq(slug.to_owned()))
264 .filter(organizations::Column::DeletedAt.is_null())
265 .one(self.db().read())
266 .await?
267 }
268 (None, None) => None,
269 }
270 .ok_or(AuthError::OrganizationNotFound)?;
271
272 let summary = self
273 .organization_summary(&organization, Some(actor_user_id))
274 .await?;
275 // 非成员只看公开字段:my_role 为空即可,不额外补一层 DTO。
276 let detail =
277 match member::find_any(self.db().read(), organization.id, actor_user_id).await? {
278 Some(row) => Some(self.member_summary(&row).await?),
279 None => None,
280 };
281 Ok((summary, detail))
282 }
283
284 pub(crate) async fn organization_summary(
285 &self,
286 organization: &organizations::Model,
287 actor_user_id: Option<i64>,
288 ) -> AuthResult<OrganizationSummary> {
289 let member_count = organization_members::Entity::find()
290 .filter(organization_members::Column::OrganizationId.eq(organization.id))
291 .filter(organization_members::Column::LeftAt.is_null())
292 .filter(organization_members::Column::BannedAt.is_null())
293 .count(self.db().read())
294 .await? as i64;
295 let my_role = match actor_user_id {
296 Some(user_id) => member::find_active(self.db().read(), organization.id, user_id)
297 .await?
298 .map(|row| row.role),
299 None => None,
300 };
301 Ok(OrganizationSummary {
302 id: organization.id,
303 slug: organization.slug.clone(),
304 name: organization.name.clone(),
305 description: organization.description.clone(),
306 avatar_url: organization.avatar_url.clone(),
307 status: organization.status,
308 owner_user_id: organization.owner_user_id,
309 member_count,
310 version: organization.version,
311 created_at: organization.created_at,
312 updated_at: organization.updated_at,
313 archived_at: organization.archived_at,
314 my_role,
315 join_policy: organization.join_policy,
316 allow_join: organization.allow_join,
317 require_email_verified: organization.require_email_verified,
318 require_mfa: organization.require_mfa,
319 welcome_message: organization.welcome_message.clone(),
320 member_must_enable_2fa: organization.member_must_enable_2fa,
321 allow_invite: organization.allow_invite,
322 allow_join_level: organization.allow_join_level,
323 })
324 }
325
326 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, user_id = user_id, request_id = %request_id.unwrap_or_default()))]
327 pub async fn list_organizations(
328 &self,
329 actor_user_id: i64,
330 user_id: i64,
331 include_archived: bool,
332 limit: u64,
333 offset: u64,
334 request_id: Option<&str>,
335 ) -> AuthResult<Vec<OrganizationSummary>> {
336 let target = if user_id == 0 { actor_user_id } else { user_id };
337 if target != actor_user_id && !self.is_platform_admin(actor_user_id).await? {
338 return Err(AuthError::Forbidden);
339 }
340
341 let ids = organization_members::Entity::find()
342 .select_only()
343 .column(organization_members::Column::OrganizationId)
344 .filter(organization_members::Column::UserId.eq(target))
345 .filter(organization_members::Column::LeftAt.is_null())
346 .filter(organization_members::Column::BannedAt.is_null())
347 .into_tuple::<Uuid>()
348 .all(self.db().read())
349 .await?;
350 if ids.is_empty() {
351 return Ok(Vec::new());
352 }
353
354 let mut query = organizations::Entity::find()
355 .filter(organizations::Column::DeletedAt.is_null())
356 .filter(organizations::Column::Id.is_in(ids));
357 if !include_archived {
358 query = query.filter(organizations::Column::Status.ne(OrganizationStatus::Archived));
359 }
360 let rows = query
361 .order_by_desc(organizations::Column::UpdatedAt)
362 .limit(limit)
363 .offset(offset)
364 .all(self.db().read())
365 .await?;
366
367 let mut out = Vec::with_capacity(rows.len());
368 for row in rows {
369 out.push(self.organization_summary(&row, Some(target)).await?);
370 }
371 Ok(out)
372 }
373
374 /// 公开目录:ACTIVE 且非 PRIVATE 的组织。不要求成员身份。
375 pub async fn list_public_organizations(
376 &self,
377 query: &str,
378 limit: u64,
379 offset: u64,
380 ) -> AuthResult<Vec<OrganizationSummary>> {
381 let mut select = organizations::Entity::find()
382 .filter(organizations::Column::DeletedAt.is_null())
383 .filter(organizations::Column::Status.eq(OrganizationStatus::Active))
384 .filter(
385 organizations::Column::JoinPolicy
386 .ne(crate::models::space::OrganizationJoinPolicy::Private),
387 );
388 let needle = query.trim();
389 if !needle.is_empty() {
390 select = select.filter(
391 Condition::any()
392 .add(organizations::Column::Slug.contains(needle))
393 .add(organizations::Column::Name.contains(needle)),
394 );
395 }
396 let rows = select
397 .order_by_desc(organizations::Column::CreatedAt)
398 .limit(limit)
399 .offset(offset)
400 .all(self.db().read())
401 .await?;
402 let mut out = Vec::with_capacity(rows.len());
403 for row in rows {
404 out.push(self.organization_summary(&row, None).await?);
405 }
406 Ok(out)
407 }
408
409 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
410 pub async fn update_organization(
411 &self,
412 actor_user_id: i64,
413 organization_id: Uuid,
414 body: UpdateOrganizationBody,
415 request_id: Option<&str>,
416 ) -> AuthResult<OrganizationSummary> {
417 self.require_permission(actor_user_id, organization_id, "org.update", request_id)
418 .await?;
419 self.check_version(organization_id, body.expected_version)
420 .await?;
421
422 let name = body.name.as_deref().map(normalize_name).transpose()?;
423 let description = match body.description.as_deref() {
424 Some(value) => Some(normalize_description(Some(value))?),
425 None => None,
426 };
427 let avatar_url = match body.avatar_url.as_deref() {
428 Some(value) => Some(normalize_avatar(Some(value))?),
429 None => None,
430 };
431
432 let txn = self.db().write().begin().await?;
433 let current = organizations::Entity::find_by_id(organization_id)
434 .filter(organizations::Column::DeletedAt.is_null())
435 .one(&txn)
436 .await?
437 .ok_or(AuthError::OrganizationNotFound)?;
438 if body.expected_version > 0 && current.version != body.expected_version {
439 return Err(AuthError::VersionMismatch {
440 current: current.version,
441 });
442 }
443 let mut active: organizations::ActiveModel = current.into();
444 if let Some(value) = name {
445 active.name = Set(value);
446 }
447 if let Some(value) = description {
448 active.description = Set(value);
449 }
450 if let Some(value) = avatar_url {
451 active.avatar_url = Set(value);
452 }
453 if let Some(value) = body.join_policy {
454 active.join_policy = Set(value);
455 }
456 if let Some(value) = body.allow_join {
457 active.allow_join = Set(value);
458 }
459 if let Some(value) = body.require_email_verified {
460 active.require_email_verified = Set(value);
461 }
462 if let Some(value) = body.require_mfa {
463 active.require_mfa = Set(value);
464 }
465 if let Some(value) = body.welcome_message.clone() {
466 let trimmed = value.trim();
467 active.welcome_message = Set(if trimmed.is_empty() {
468 None
469 } else {
470 Some(trimmed.to_string())
471 });
472 }
473 if let Some(value) = body.member_must_enable_2fa {
474 active.member_must_enable_2fa = Set(value);
475 }
476 if let Some(value) = body.allow_invite {
477 active.allow_invite = Set(value);
478 }
479 if let Some(value) = body.allow_join_level {
480 active.allow_join_level = Set(valid_join_level(value)?);
481 }
482 let updated = active.update(&txn).await?;
483 let version = bump_version(&txn, organization_id).await?;
484 self.emit_org(
485 &txn,
486 label::ORGANIZATION_LIFE_CYCLE_UPDATE,
487 organization_id,
488 actor_user_id,
489 serde_json::json!({
490 "id": organization_id,
491 "name": &updated.name,
492 "request_id": request_id,
493 }),
494 request_id,
495 )
496 .await?;
497 txn.commit().await?;
498
499 self.invalidate_org(organization_id).await?;
500 let refreshed = self.load_organization(organization_id).await?;
501 let mut summary = self
502 .organization_summary(&refreshed, Some(actor_user_id))
503 .await?;
504 summary.version = version;
505 Ok(summary)
506 }
507
508 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
509 pub async fn archive_organization(
510 &self,
511 actor_user_id: i64,
512 organization_id: Uuid,
513 reason: Option<&str>,
514 request_id: Option<&str>,
515 ) -> AuthResult<OrganizationSummary> {
516 self.require_permission(actor_user_id, organization_id, "org.archive", request_id)
517 .await?;
518 let reason = normalize_reason(reason)?;
519
520 let txn = self.db().write().begin().await?;
521 organizations::Entity::update_many()
522 .col_expr(
523 organizations::Column::Status,
524 Expr::value(OrganizationStatus::Archived),
525 )
526 .col_expr(organizations::Column::ArchivedAt, Expr::value(Utc::now()))
527 .col_expr(organizations::Column::SuspendedReason, Expr::value(reason))
528 .filter(organizations::Column::Id.eq(organization_id))
529 .filter(organizations::Column::DeletedAt.is_null())
530 .exec(&txn)
531 .await?;
532 let version = bump_version(&txn, organization_id).await?;
533 self.emit_org(
534 &txn,
535 label::ORGANIZATION_LIFE_CYCLE_UPDATE,
536 organization_id,
537 actor_user_id,
538 serde_json::json!({
539 "id": organization_id,
540 "action": "archive",
541 "request_id": request_id,
542 }),
543 request_id,
544 )
545 .await?;
546 txn.commit().await?;
547
548 self.invalidate_org(organization_id).await?;
549 let refreshed = self.load_organization(organization_id).await?;
550 let mut summary = self
551 .organization_summary(&refreshed, Some(actor_user_id))
552 .await?;
553 summary.version = version;
554 Ok(summary)
555 }
556
557 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %request_id.unwrap_or_default()))]
558 pub async fn restore_organization(
559 &self,
560 actor_user_id: i64,
561 organization_id: Uuid,
562 request_id: Option<&str>,
563 ) -> AuthResult<OrganizationSummary> {
564 // 归档组织的权限判定一律不通过,所以这里不能用 require_permission:
565 // 恢复动作由 OWNER 或平台管理员执行。
566 let organization = self.load_organization(organization_id).await?;
567 if organization.owner_user_id != actor_user_id
568 && !self.is_platform_admin(actor_user_id).await?
569 {
570 return Err(AuthError::Forbidden);
571 }
572
573 let txn = self.db().write().begin().await?;
574 organizations::Entity::update_many()
575 .col_expr(
576 organizations::Column::Status,
577 Expr::value(OrganizationStatus::Active),
578 )
579 .col_expr(
580 organizations::Column::ArchivedAt,
581 Expr::value(None::<chrono::DateTime<Utc>>),
582 )
583 .col_expr(
584 organizations::Column::SuspendedReason,
585 Expr::value(None::<String>),
586 )
587 .filter(organizations::Column::Id.eq(organization_id))
588 .exec(&txn)
589 .await?;
590 let version = bump_version(&txn, organization_id).await?;
591 self.emit_org(
592 &txn,
593 label::ORGANIZATION_LIFE_CYCLE_UPDATE,
594 organization_id,
595 actor_user_id,
596 serde_json::json!({
597 "id": organization_id,
598 "action": "restore",
599 "request_id": request_id,
600 }),
601 request_id,
602 )
603 .await?;
604 txn.commit().await?;
605
606 self.invalidate_org(organization_id).await?;
607 let refreshed = self.load_organization(organization_id).await?;
608 let mut summary = self
609 .organization_summary(&refreshed, Some(actor_user_id))
610 .await?;
611 summary.version = version;
612 Ok(summary)
613 }
614
615 /// 删除分两种语义:`force = false` 时仍有活跃成员会被拦下,
616 /// `force = true` 时才连成员与角色一并清理。软删标记写在 `deleted_at` 上,
617 /// 但权限缓存与派生数据必须真的清掉,否则残留的授予会在重建同名组织时复活。
618 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, force = force, request_id = %request_id.unwrap_or_default()))]
619 pub async fn delete_organization(
620 &self,
621 actor_user_id: i64,
622 organization_id: Uuid,
623 force: bool,
624 reason: Option<&str>,
625 request_id: Option<&str>,
626 ) -> AuthResult<bool> {
627 let organization = self.load_organization(organization_id).await?;
628 if organization.owner_user_id != actor_user_id
629 && !self.is_platform_admin(actor_user_id).await?
630 {
631 return Err(AuthError::Forbidden);
632 }
633 let reason = normalize_reason(reason)?;
634
635 if !force {
636 let active = organization_members::Entity::find()
637 .filter(organization_members::Column::OrganizationId.eq(organization_id))
638 .filter(organization_members::Column::LeftAt.is_null())
639 .filter(organization_members::Column::BannedAt.is_null())
640 .count(self.db().read())
641 .await?;
642 if active > 0 {
643 return Err(AuthError::OrganizationHasMembers);
644 }
645 }
646
647 let txn = self.db().write().begin().await?;
648 let member_ids = organization_members::Entity::find()
649 .select_only()
650 .column(organization_members::Column::Id)
651 .filter(organization_members::Column::OrganizationId.eq(organization_id))
652 .into_tuple::<Uuid>()
653 .all(&txn)
654 .await?;
655 // 权限缓存键依赖 user_id,而成员行在本事务里会被物理删除,所以先把 user_id 取出来。
656 let member_user_ids = organization_members::Entity::find()
657 .select_only()
658 .column(organization_members::Column::UserId)
659 .filter(organization_members::Column::OrganizationId.eq(organization_id))
660 .into_tuple::<i64>()
661 .all(&txn)
662 .await?;
663 if !member_ids.is_empty() {
664 organization_member_permissions::Entity::delete_many()
665 .filter(organization_member_permissions::Column::MemberId.is_in(member_ids.clone()))
666 .exec(&txn)
667 .await?;
668 organization_member_roles::Entity::delete_many()
669 .filter(organization_member_roles::Column::MemberId.is_in(member_ids))
670 .exec(&txn)
671 .await?;
672 organization_members::Entity::delete_many()
673 .filter(organization_members::Column::OrganizationId.eq(organization_id))
674 .exec(&txn)
675 .await?;
676 }
677 organization_invite_redemptions::Entity::delete_many()
678 .filter(organization_invite_redemptions::Column::OrganizationId.eq(organization_id))
679 .exec(&txn)
680 .await?;
681 organization_invites::Entity::delete_many()
682 .filter(organization_invites::Column::OrganizationId.eq(organization_id))
683 .exec(&txn)
684 .await?;
685 let role_ids = organization_roles::Entity::find()
686 .select_only()
687 .column(organization_roles::Column::Id)
688 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
689 .into_tuple::<Uuid>()
690 .all(&txn)
691 .await?;
692 if !role_ids.is_empty() {
693 organization_role_permissions::Entity::delete_many()
694 .filter(organization_role_permissions::Column::RoleId.is_in(role_ids))
695 .exec(&txn)
696 .await?;
697 }
698 organization_roles::Entity::delete_many()
699 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
700 .exec(&txn)
701 .await?;
702 organizations::Entity::update_many()
703 .col_expr(organizations::Column::DeletedAt, Expr::value(Utc::now()))
704 .col_expr(
705 organizations::Column::SuspendedReason,
706 Expr::value(reason.clone()),
707 )
708 .filter(organizations::Column::Id.eq(organization_id))
709 .exec(&txn)
710 .await?;
711 bump_version(&txn, organization_id).await?;
712 self.emit_org(
713 &txn,
714 label::ORGANIZATION_LIFE_CYCLE_DELETE,
715 organization_id,
716 actor_user_id,
717 serde_json::json!({
718 "id": organization_id,
719 "force": force,
720 "reason": &reason,
721 "request_id": request_id,
722 }),
723 request_id,
724 )
725 .await?;
726 txn.commit().await?;
727
728 self.drop_org_cache(organization_id, member_user_ids)
729 .await?;
730 Ok(true)
731 }
732
733 #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, target_user_id = target_user_id, request_id = %request_id.unwrap_or_default()))]
734 pub async fn transfer_ownership(
735 &self,
736 actor_user_id: i64,
737 organization_id: Uuid,
738 target_user_id: i64,
739 request_id: Option<&str>,
740 ) -> AuthResult<OrganizationSummary> {
741 let organization = self.load_organization(organization_id).await?;
742 if organization.owner_user_id != actor_user_id
743 && !self.is_platform_admin(actor_user_id).await?
744 {
745 return Err(AuthError::Forbidden);
746 }
747 if organization.status != OrganizationStatus::Active {
748 return Err(AuthError::OrganizationInactive);
749 }
750 if target_user_id == organization.owner_user_id {
751 return Err(AuthError::OrganizationMemberExists);
752 }
753 let target = member::find_active(self.db().read(), organization_id, target_user_id)
754 .await?
755 .ok_or(AuthError::OrganizationMemberNotFound)?;
756 let owner_role = role::find_by_key(organization_id, SYSTEM_ROLE_OWNER, self.db().read())
757 .await?
758 .ok_or_else(|| AuthError::internal("owner role is missing"))?;
759 let admin_role = role::find_by_key(organization_id, SYSTEM_ROLE_ADMIN, self.db().read())
760 .await?
761 .ok_or_else(|| AuthError::internal("admin role is missing"))?;
762
763 let txn = self.db().write().begin().await?;
764 // 唯一 OWNER 的不变式落在两处:organizations.owner_user_id 与成员行的粗粒度角色。
765 // 两个改写放在同一事务里,读到中间态的可能性为零。
766 if let Some(previous) =
767 member::find_any(&txn, organization_id, organization.owner_user_id).await?
768 {
769 let mut active: organization_members::ActiveModel = previous.clone().into();
770 active.role = Set(OrgMemberRole::Admin);
771 active.update(&txn).await?;
772 role::unbind_role(&txn, previous.id, owner_role.id).await?;
773 role::bind_role(&txn, previous.id, admin_role.id, actor_user_id).await?;
774 }
775 let mut active: organization_members::ActiveModel = target.clone().into();
776 active.role = Set(OrgMemberRole::Owner);
777 active.update(&txn).await?;
778 role::unbind_role(&txn, target.id, admin_role.id).await?;
779 role::bind_role(&txn, target.id, owner_role.id, actor_user_id).await?;
780 organizations::Entity::update_many()
781 .col_expr(
782 organizations::Column::OwnerUserId,
783 Expr::value(target_user_id),
784 )
785 .filter(organizations::Column::Id.eq(organization_id))
786 .exec(&txn)
787 .await?;
788 let version = bump_version(&txn, organization_id).await?;
789 self.emit_org(
790 &txn,
791 label::ORGANIZATION_MEMBER_UPDATE,
792 organization_id,
793 actor_user_id,
794 serde_json::json!({
795 "id": organization_id,
796 "action": "transfer_ownership",
797 "previous_owner": organization.owner_user_id,
798 "new_owner": target_user_id,
799 "request_id": request_id,
800 }),
801 request_id,
802 )
803 .await?;
804 txn.commit().await?;
805
806 self.invalidate_org(organization_id).await?;
807 let refreshed = self.load_organization(organization_id).await?;
808 let mut summary = self
809 .organization_summary(&refreshed, Some(actor_user_id))
810 .await?;
811 summary.version = version;
812 Ok(summary)
813 }
814
815 pub(crate) async fn load_organization(
816 &self,
817 organization_id: Uuid,
818 ) -> AuthResult<organizations::Model> {
819 organizations::Entity::find_by_id(organization_id)
820 .filter(organizations::Column::DeletedAt.is_null())
821 .one(self.db().read())
822 .await?
823 .ok_or(AuthError::OrganizationNotFound)
824 }
825
826 pub(crate) async fn member_summary(
827 &self,
828 row: &organization_members::Model,
829 ) -> AuthResult<MemberSummary> {
830 let user = users::Entity::find_by_id(row.user_id)
831 .one(self.db().read())
832 .await?;
833 Ok(MemberSummary {
834 id: row.id,
835 organization_id: row.organization_id,
836 user_id: row.user_id,
837 username: user
838 .as_ref()
839 .map(|u| u.username.clone())
840 .unwrap_or_default(),
841 displayname: user.and_then(|u| u.displayname),
842 role: row.role,
843 role_keys: role::role_keys_of(self.db().read(), row.id).await?,
844 permissions: permission::effective(self.db().read(), row).await?,
845 joined_at: row.joined_at,
846 left_at: row.left_at,
847 banned_at: row.banned_at,
848 invited_by: row.invited_by,
849 join_method: row.join_method,
850 alias_name: row.alias_name.clone(),
851 alias_avatar_url: row.alias_avatar_url.clone(),
852 message_notifications: row.message_notifications,
853 muted: row.muted,
854 mute_until: row.mute_until,
855 mobile_push: row.mobile_push,
856 suppress_everyone: row.suppress_everyone,
857 suppress_roles: row.suppress_roles,
858 hide_muted_channels: row.hide_muted_channels,
859 ban_reason: row.ban_reason.clone(),
860 ban_expires_at: row.ban_expires_at,
861 ban_allow_agent_join: row.ban_allow_agent_join,
862 ban_allow_speak: row.ban_allow_speak,
863 ban_allow_push_repo: row.ban_allow_push_repo,
864 ban_allow_comment_any: row.ban_allow_comment_any,
865 version: row.version,
866 last_active_at: row.last_active_at,
867 })
868 }
869
870 pub(crate) async fn role_summary(
871 &self,
872 row: &organization_roles::Model,
873 ) -> AuthResult<RoleSummary> {
874 let permissions = role::grants_of(self.db().read(), row.id).await?;
875 let member_count = organization_member_roles::Entity::find()
876 .filter(organization_member_roles::Column::RoleId.eq(row.id))
877 .count(self.db().read())
878 .await? as i64;
879 Ok(RoleSummary {
880 id: row.id,
881 organization_id: row.organization_id,
882 key: row.key.clone(),
883 name: row.name.clone(),
884 description: row.description.clone(),
885 rank: row.rank,
886 system: row.system,
887 default_role: row.default_role,
888 self_assignable: row.self_assignable,
889 permissions,
890 member_count,
891 created_at: row.created_at,
892 updated_at: row.updated_at,
893 version: row.version,
894 color: row.color.clone(),
895 emoji: row.emoji.clone(),
896 icon_hash: row.icon_hash.clone(),
897 position: row.position,
898 })
899 }
900
901 pub(crate) async fn default_role_id(&self, organization_id: Uuid) -> AuthResult<Option<Uuid>> {
902 Ok(organization_roles::Entity::find()
903 .filter(organization_roles::Column::OrganizationId.eq(organization_id))
904 .filter(organization_roles::Column::DefaultRole.eq(true))
905 .order_by_asc(organization_roles::Column::Rank)
906 .one(self.db().read())
907 .await?
908 .map(|row| row.id))
909 }
910
911 pub(crate) fn validate_grants(grants: &[PermissionGrant]) -> AuthResult<Vec<PermissionGrant>> {
912 let mut out = Vec::with_capacity(grants.len());
913 for grant in grants {
914 let permission = grant.permission.trim();
915 if permission.is_empty()
916 || permission.len() > MAX_PERMISSION_LEN
917 || !is_known_permission(permission)
918 {
919 return Err(AuthError::InvalidPermission {
920 permission: grant.permission.clone(),
921 });
922 }
923 out.push(PermissionGrant {
924 permission: permission.to_owned(),
925 effect: match grant.effect {
926 PermissionEffect::Deny => PermissionEffect::Deny,
927 _ => PermissionEffect::Allow,
928 },
929 });
930 }
931 Ok(out)
932 }
933}
934
935/// 在写事务内推进 version 并回读新值。
936pub(crate) async fn bump_version<C>(db: &C, organization_id: Uuid) -> AuthResult<i32>
937where
938 C: sea_orm::ConnectionTrait,
939{
940 let bumped = organizations::Entity::update_many()
941 .col_expr(
942 organizations::Column::Version,
943 Expr::col(organizations::Column::Version).add(1),
944 )
945 .col_expr(organizations::Column::UpdatedAt, Expr::value(Utc::now()))
946 .filter(organizations::Column::Id.eq(organization_id))
947 .exec(db)
948 .await?;
949 if bumped.rows_affected == 0 {
950 return Err(AuthError::OrganizationNotFound);
951 }
952 organizations::Entity::find_by_id(organization_id)
953 .select_only()
954 .column(organizations::Column::Version)
955 .into_tuple::<i32>()
956 .one(db)
957 .await?
958 .ok_or(AuthError::OrganizationNotFound)
959}
960
961pub(crate) fn normalize_slug(raw: &str) -> AuthResult<String> {
962 let slug = raw.trim().to_lowercase();
963 if slug.is_empty()
964 || slug.len() > MAX_SLUG_LEN
965 || !slug
966 .chars()
967 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
968 || slug.starts_with('-')
969 || slug.ends_with('-')
970 {
971 return Err(AuthError::MalformedRequest);
972 }
973 Ok(slug)
974}
975
976pub(crate) fn normalize_name(raw: &str) -> AuthResult<String> {
977 let name = raw.trim();
978 if name.is_empty() || name.len() > MAX_ORG_NAME_LEN {
979 return Err(AuthError::MalformedRequest);
980 }
981 Ok(name.to_owned())
982}
983
984pub(crate) fn normalize_description(raw: Option<&str>) -> AuthResult<Option<String>> {
985 match raw.map(str::trim) {
986 None | Some("") => Ok(None),
987 Some(value) if value.len() > MAX_ORG_DESCRIPTION_LEN => Err(AuthError::MalformedRequest),
988 Some(value) => Ok(Some(value.to_owned())),
989 }
990}
991
992pub(crate) fn normalize_avatar(raw: Option<&str>) -> AuthResult<Option<String>> {
993 match raw.map(str::trim) {
994 None | Some("") => Ok(None),
995 Some(value) if value.len() > 512 || !value.starts_with("https://") => {
996 Err(AuthError::MalformedRequest)
997 }
998 Some(value) => Ok(Some(value.to_owned())),
999 }
1000}
1001
1002pub(crate) fn normalize_reason(raw: Option<&str>) -> AuthResult<Option<String>> {
1003 match raw.map(str::trim) {
1004 None | Some("") => Ok(None),
1005 Some(value) if value.len() > 512 => Err(AuthError::MalformedRequest),
1006 Some(value) => Ok(Some(value.to_owned())),
1007 }
1008}
1009
1010pub(crate) fn normalize_role_key(raw: &str) -> AuthResult<String> {
1011 let key = raw.trim().to_lowercase();
1012 if key.is_empty()
1013 || key.len() > MAX_ROLE_KEY_LEN
1014 || !key
1015 .chars()
1016 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-')
1017 {
1018 return Err(AuthError::MalformedRequest);
1019 }
1020 Ok(key)
1021}
1022
1023pub(crate) fn normalize_role_name(raw: &str) -> AuthResult<String> {
1024 let name = raw.trim();
1025 if name.is_empty() || name.len() > MAX_ROLE_NAME_LEN {
1026 return Err(AuthError::MalformedRequest);
1027 }
1028 Ok(name.to_owned())
1029}
1030
1031pub(crate) fn valid_join_level(value: i16) -> AuthResult<i16> {
1032 (0..=4)
1033 .contains(&value)
1034 .then_some(value)
1035 .ok_or(AuthError::MalformedRequest)
1036}
Deleted lib/auth/src/handlers/identity/organization/types.rs
@@ -1,422 +0,0 @@
1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Deserializer, Serialize};
3use utoipa::ToSchema;
4use uuid::Uuid;
5
6use crate::models::space::{
7 MessageNotifications, OrgJoinMethod, OrgMemberRole, OrganizationJoinPolicy,
8 OrganizationJoinRequestStatus, OrganizationStatus, PermissionEffect,
9};
10
11/// 组织内权限键白名单。写入口一律先过这里,未知键直接拒绝;
12/// 允许自由字符串会让权限判定变成"拼写是否正确"的赌博。
13pub const PERMISSIONS: &[&str] = &[
14 "org.view",
15 "org.update",
16 "org.archive",
17 "org.restore",
18 "org.delete",
19 "org.transfer_ownership",
20 "member.view",
21 "member.invite",
22 "member.add",
23 "member.remove",
24 "member.ban",
25 "member.role.update",
26 "role.view",
27 "role.create",
28 "role.update",
29 "role.delete",
30 "role.permission.update",
31 "role.assign",
32 "invite.view",
33 "invite.create",
34 "invite.revoke",
35 "audit.view",
36 "space.view_space",
37 "space.read",
38 "space.message",
39 "space.manage_members",
40 "space.manage_roles",
41 "space.manage_invites",
42 "space.manage_bans",
43 "space.manage_channels",
44 "space.manage_overwrites",
45];
46
47/// ADMIN 固有集。命中这里的键不看角色绑定,直接通过。
48/// 刻意不含 `org.delete` / `org.transfer_ownership`:这两个是 Owner 专属。
49pub const ADMIN_PERMISSIONS: &[&str] = &[
50 "org.view",
51 "org.update",
52 "org.archive",
53 "org.restore",
54 "member.view",
55 "member.invite",
56 "member.add",
57 "member.remove",
58 "member.ban",
59 "member.role.update",
60 "role.view",
61 "role.create",
62 "role.update",
63 "role.delete",
64 "role.permission.update",
65 "role.assign",
66 "invite.view",
67 "invite.create",
68 "invite.revoke",
69 "audit.view",
70 "space.view_space",
71 "space.read",
72 "space.message",
73 "space.manage_members",
74 "space.manage_roles",
75 "space.manage_invites",
76 "space.manage_bans",
77 "space.manage_channels",
78 "space.manage_overwrites",
79];
80
81pub const MAX_PERMISSION_LEN: usize = 64;
82pub const MAX_BATCH_PERMISSIONS: usize = 32;
83pub const MAX_SLUG_LEN: usize = 32;
84pub const MAX_ORG_NAME_LEN: usize = 128;
85pub const MAX_ORG_DESCRIPTION_LEN: usize = 1024;
86pub const MAX_ROLE_KEY_LEN: usize = 64;
87pub const MAX_ROLE_NAME_LEN: usize = 64;
88pub const MAX_REASON_LEN: usize = 512;
89pub const PERMISSION_CACHE_TTL_SECS: u64 = 300;
90pub const MAX_SLUG_ATTEMPTS: usize = 8;
91
92pub const SYSTEM_ROLE_OWNER: &str = "owner";
93pub const SYSTEM_ROLE_ADMIN: &str = "admin";
94pub const SYSTEM_ROLE_MEMBER: &str = "member";
95pub const DEFAULT_INVITE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
96
97pub fn is_known_permission(permission: &str) -> bool {
98 PERMISSIONS.contains(&permission)
99}
100
101pub fn normalize_permission(permission: &str) -> Option<&'static str> {
102 let trimmed = permission.trim();
103 PERMISSIONS.iter().copied().find(|known| *known == trimmed)
104}
105
106pub fn is_admin_permission(permission: &str) -> bool {
107 ADMIN_PERMISSIONS.contains(&permission)
108}
109
110#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
111pub struct OrganizationSummary {
112 pub id: Uuid,
113 pub slug: String,
114 pub name: String,
115 pub description: Option<String>,
116 pub avatar_url: Option<String>,
117 pub status: OrganizationStatus,
118 pub owner_user_id: i64,
119 pub member_count: i64,
120 pub version: i32,
121 pub created_at: DateTime<Utc>,
122 pub updated_at: DateTime<Utc>,
123 pub archived_at: Option<DateTime<Utc>>,
124 pub my_role: Option<OrgMemberRole>,
125 pub join_policy: OrganizationJoinPolicy,
126 pub allow_join: bool,
127 pub require_email_verified: bool,
128 pub require_mfa: bool,
129 pub welcome_message: Option<String>,
130 pub member_must_enable_2fa: bool,
131 pub allow_invite: bool,
132 pub allow_join_level: i16,
133}
134
135#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
136pub struct MemberSummary {
137 pub id: Uuid,
138 pub organization_id: Uuid,
139 pub user_id: i64,
140 pub username: String,
141 pub displayname: Option<String>,
142 pub role: OrgMemberRole,
143 pub role_keys: Vec<String>,
144 pub permissions: Vec<String>,
145 pub joined_at: DateTime<Utc>,
146 pub left_at: Option<DateTime<Utc>>,
147 pub banned_at: Option<DateTime<Utc>>,
148 pub invited_by: Option<i64>,
149 pub join_method: OrgJoinMethod,
150 pub alias_name: Option<String>,
151 pub alias_avatar_url: Option<String>,
152 pub message_notifications: MessageNotifications,
153 pub muted: bool,
154 pub mute_until: Option<DateTime<Utc>>,
155 pub mobile_push: bool,
156 pub suppress_everyone: bool,
157 pub suppress_roles: bool,
158 pub hide_muted_channels: bool,
159 pub ban_reason: Option<String>,
160 pub ban_expires_at: Option<DateTime<Utc>>,
161 pub ban_allow_agent_join: bool,
162 pub ban_allow_speak: bool,
163 pub ban_allow_push_repo: bool,
164 pub ban_allow_comment_any: bool,
165 pub version: i32,
166 pub last_active_at: Option<DateTime<Utc>>,
167}
168
169#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
170pub struct PermissionGrant {
171 pub permission: String,
172 pub effect: PermissionEffect,
173}
174
175#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
176pub struct RoleSummary {
177 pub id: Uuid,
178 pub organization_id: Uuid,
179 pub key: String,
180 pub name: String,
181 pub description: Option<String>,
182 pub rank: i32,
183 pub system: bool,
184 pub default_role: bool,
185 pub self_assignable: bool,
186 pub permissions: Vec<PermissionGrant>,
187 pub member_count: i64,
188 pub created_at: DateTime<Utc>,
189 pub updated_at: DateTime<Utc>,
190 pub version: i32,
191 pub color: Option<String>,
192 pub emoji: Option<String>,
193 pub icon_hash: Option<String>,
194 pub position: i64,
195}
196
197#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
198pub struct InviteSummary {
199 pub id: Uuid,
200 pub organization_id: Uuid,
201 pub method: OrgJoinMethod,
202 pub role_id: Option<Uuid>,
203 pub target_user_id: Option<i64>,
204 pub created_by: i64,
205 pub max_uses: i32,
206 pub uses: i32,
207 pub expires_at: Option<DateTime<Utc>>,
208 pub revoked_at: Option<DateTime<Utc>>,
209 pub created_at: DateTime<Utc>,
210 pub email: Option<String>,
211}
212
213#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
214pub struct PermissionCheck {
215 pub allowed: bool,
216 pub reason: String,
217 pub matched_by: String,
218 pub organization_version: i32,
219}
220
221#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
222pub struct CreateOrganizationBody {
223 pub slug: String,
224 pub name: String,
225 pub description: Option<String>,
226 pub avatar_url: Option<String>,
227 #[serde(default)]
228 pub join_policy: Option<OrganizationJoinPolicy>,
229 #[serde(default)]
230 pub allow_join: Option<bool>,
231 #[serde(default)]
232 pub require_email_verified: Option<bool>,
233 #[serde(default)]
234 pub require_mfa: Option<bool>,
235 #[serde(default)]
236 pub welcome_message: Option<String>,
237 #[serde(default)]
238 pub member_must_enable_2fa: Option<bool>,
239 #[serde(default)]
240 pub allow_invite: Option<bool>,
241 #[serde(default)]
242 pub allow_join_level: Option<i16>,
243}
244
245#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
246pub struct UpdateOrganizationBody {
247 pub name: Option<String>,
248 pub description: Option<String>,
249 pub avatar_url: Option<String>,
250 #[serde(default)]
251 pub expected_version: i32,
252 #[serde(default)]
253 pub join_policy: Option<OrganizationJoinPolicy>,
254 #[serde(default)]
255 pub allow_join: Option<bool>,
256 #[serde(default)]
257 pub require_email_verified: Option<bool>,
258 #[serde(default)]
259 pub require_mfa: Option<bool>,
260 #[serde(default)]
261 pub welcome_message: Option<String>,
262 #[serde(default)]
263 pub member_must_enable_2fa: Option<bool>,
264 #[serde(default)]
265 pub allow_invite: Option<bool>,
266 #[serde(default)]
267 pub allow_join_level: Option<i16>,
268}
269
270#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
271pub struct AddMemberBody {
272 pub user_id: i64,
273 #[serde(default)]
274 pub role: Option<OrgMemberRole>,
275 #[serde(default)]
276 pub role_keys: Vec<String>,
277}
278
279#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
280pub struct SetMemberRoleBody {
281 pub role: OrgMemberRole,
282 #[serde(default)]
283 pub expected_version: i32,
284}
285
286#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
287pub struct BanMemberBody {
288 pub reason: Option<String>,
289}
290
291#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
292pub struct CheckPermissionBody {
293 pub permission: String,
294 #[serde(default)]
295 pub user_id: Option<i64>,
296}
297
298#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
299pub struct CreateRoleBody {
300 pub key: String,
301 pub name: String,
302 pub description: Option<String>,
303 #[serde(default)]
304 pub rank: i32,
305 #[serde(default)]
306 pub default_role: bool,
307 #[serde(default)]
308 pub self_assignable: bool,
309 #[serde(default)]
310 pub permissions: Vec<PermissionGrant>,
311}
312
313#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
314pub struct SetRolePermissionsBody {
315 #[serde(default)]
316 pub permissions: Vec<PermissionGrant>,
317}
318
319/// 反序列化 `Option<Option<T>>`:字段缺失 → `None`,显式 `null` → `Some(None)`,
320/// 有值 → `Some(Some(v))`。PATCH 接口用它区分「没传」与「清空」。
321fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
322where
323 T: Deserialize<'de>,
324 D: Deserializer<'de>,
325{
326 Deserialize::deserialize(deserializer).map(Some)
327}
328
329/// 会籍更新:管理端可改别名、通知偏好、免打扰与封禁附加信息。
330/// 所有字段缺失表示不变,显式 null 表示清空(仅限 Option 字段)。
331#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
332pub struct UpdateMemberBody {
333 #[serde(default, deserialize_with = "double_option")]
334 pub alias_name: Option<Option<String>>,
335 #[serde(default, deserialize_with = "double_option")]
336 pub alias_avatar_url: Option<Option<String>>,
337 #[serde(default)]
338 pub message_notifications: Option<MessageNotifications>,
339 #[serde(default)]
340 pub muted: Option<bool>,
341 #[serde(default, deserialize_with = "double_option")]
342 pub mute_until: Option<Option<DateTime<Utc>>>,
343 #[serde(default)]
344 pub mobile_push: Option<bool>,
345 #[serde(default)]
346 pub suppress_everyone: Option<bool>,
347 #[serde(default)]
348 pub suppress_roles: Option<bool>,
349 #[serde(default)]
350 pub hide_muted_channels: Option<bool>,
351 #[serde(default)]
352 pub expected_version: i32,
353}
354
355/// 封禁更新:原因、到期时间与四个放行位。缺失表示不变,null 表示清空。
356#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
357pub struct UpdateBanBody {
358 #[serde(default, deserialize_with = "double_option")]
359 pub reason: Option<Option<String>>,
360 #[serde(default, deserialize_with = "double_option")]
361 pub expires_at: Option<Option<DateTime<Utc>>>,
362 #[serde(default)]
363 pub allow_agent_join: Option<bool>,
364 #[serde(default)]
365 pub allow_speak: Option<bool>,
366 #[serde(default)]
367 pub allow_push_repo: Option<bool>,
368 #[serde(default)]
369 pub allow_comment_any: Option<bool>,
370}
371
372pub const MAX_ALIAS_NAME_LEN: usize = 64;
373pub const MAX_ALIAS_AVATAR_URL_LEN: usize = 512;
374
375/// 校验并规范化别名:空白视为清空,超长返回 `Err(())`,由调用方统一翻译成 400。
376#[allow(clippy::result_unit_err)]
377pub fn normalize_alias(value: &str, _field: &str, max: usize) -> Result<Option<String>, ()> {
378 let trimmed = value.trim();
379 if trimmed.is_empty() {
380 return Ok(None);
381 }
382 if trimmed.len() > max {
383 return Err(());
384 }
385 Ok(Some(trimmed.to_owned()))
386}
387
388/// 内部使用的解析结果。`matched_by` 取值固定为 owner / admin / role / direct / default_deny,
389/// 只有 reason 为空时 allowed 才可能为 true。
390pub struct Resolution {
391 pub allowed: bool,
392 pub reason: &'static str,
393 pub matched_by: &'static str,
394 pub effective: Vec<String>,
395}
396
397#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
398pub struct JoinRuleSummary {
399 pub id: Uuid,
400 pub organization_id: Uuid,
401 pub role_id: Uuid,
402 pub trigger: String,
403 pub matcher: String,
404 pub enabled: bool,
405 pub created_at: DateTime<Utc>,
406 pub updated_at: DateTime<Utc>,
407}
408
409#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
410pub struct JoinRequestSummary {
411 pub id: Uuid,
412 pub organization_id: Uuid,
413 pub user_id: i64,
414 pub username: String,
415 pub displayname: Option<String>,
416 pub status: OrganizationJoinRequestStatus,
417 pub note: Option<String>,
418 pub decided_by: Option<i64>,
419 pub decided_at: Option<DateTime<Utc>>,
420 pub created_at: DateTime<Utc>,
421 pub updated_at: DateTime<Utc>,
422}
Modified lib/auth/src/handlers/identity/ssh_key/handler.rs View lib/auth/src/handlers/identity/ssh_key/handler.rs
@@ -4,8 +4,8 @@
44
55use crate::envelope::{Envelope, NoData};
66use crate::errors::AuthError;
7use crate::handlers::identity::ssh_key::types::{SshKeyAddRequest, SshKeySummary};
87use crate::share::types::ListQuery;
8use crate::ssh_key::types::{SshKeyAddRequest, SshKeySummary};
99
1010#[utoipa::path(
1111 get,
Modified lib/auth/src/handlers/identity/ssh_key/mod.rs View lib/auth/src/handlers/identity/ssh_key/mod.rs
@@ -1,5 +1 @@
11pub mod handler;
2pub mod service;
3pub mod types;
4
5pub use types::*;
Deleted lib/auth/src/handlers/identity/ssh_key/service.rs
@@ -1,294 +0,0 @@
1use crate::events::{Labels, label};
2use crate::models::users::user_events::{self, UserEventType};
3use crate::models::users::user_ssh_keys::{self, PublicKeyType};
4use crate::models::users::users;
5use crate::session::Session;
6use base64::Engine;
7use base64::engine::general_purpose::STANDARD_NO_PAD;
8use chrono::Utc;
9use sea_orm::entity::Set;
10use sea_orm::sea_query::{Expr, ExprTrait};
11use sea_orm::{
12 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, SqlErr,
13 TransactionTrait,
14};
15use uuid::Uuid;
16
17use crate::AuthSvc;
18use crate::errors::{AuthError, AuthResult};
19use crate::handlers::identity::ssh_key::types::{
20 MAX_SSH_KEY_DESCRIPTION_LEN, MAX_SSH_KEY_NAME_LEN, MAX_SSH_PUBLIC_KEY_LEN,
21 MIN_SSH_PUBLIC_KEY_LEN, SshKeyAddRequest, SshKeyAlgorithm, SshKeySummary,
22};
23use crate::share::crypto;
24use crate::share::types::ListQuery;
25
26pub(crate) fn parse_public_key(raw: &str) -> AuthResult<(PublicKeyType, String)> {
27 let key_line = raw.trim();
28 if key_line.len() < MIN_SSH_PUBLIC_KEY_LEN || key_line.len() > MAX_SSH_PUBLIC_KEY_LEN {
29 return Err(AuthError::SshKeyInvalid);
30 }
31
32 let mut parts = key_line.split_whitespace();
33 let declared_type = parts.next().ok_or(AuthError::SshKeyInvalid)?;
34 let blob_text = parts.next().ok_or(AuthError::SshKeyInvalid)?;
35 let algorithm = match declared_type {
36 "ssh-rsa" => PublicKeyType::RSA,
37 "ssh-ed25519" => PublicKeyType::ED25519,
38 "ecdsa-sha2-nistp256" => PublicKeyType::ECDSA,
39 _ => return Err(AuthError::SshKeyInvalid),
40 };
41
42 let blob = STANDARD_NO_PAD
43 .decode(blob_text.trim_end_matches('='))
44 .map_err(|_| AuthError::SshKeyInvalid)?;
45 if blob.len() < 8 {
46 return Err(AuthError::SshKeyInvalid);
47 }
48
49 let header_len = u32::from_be_bytes([blob[0], blob[1], blob[2], blob[3]]) as usize;
50 if header_len == 0 || 4 + header_len > blob.len() {
51 return Err(AuthError::SshKeyInvalid);
52 }
53 let header =
54 std::str::from_utf8(&blob[4..4 + header_len]).map_err(|_| AuthError::SshKeyInvalid)?;
55 if header != declared_type {
56 return Err(AuthError::SshKeyInvalid);
57 }
58
59 Ok((
60 algorithm,
61 format!("SHA256:{}", crypto::sha256_b64_standard(&blob)),
62 ))
63}
64
65fn summary(record: user_ssh_keys::Model) -> SshKeySummary {
66 SshKeySummary {
67 id: record.id,
68 name: record.name,
69 description: record.description,
70 algorithm: SshKeyAlgorithm::from(record.algorithm),
71 fingerprint: record.fingerprint,
72 public_key: record.public_key,
73 created_at: record.created_at,
74 expires_at: record.expires_at,
75 last_used_at: record.last_used_at,
76 revoked_at: record.revoked_at,
77 }
78}
79
80impl AuthSvc {
81 #[tracing::instrument(skip_all, fields(request_id = %request_id, client_ip = ?session.client_ip().ok().flatten()))]
82 pub async fn add_ssh_key(
83 &self,
84 session: Session,
85 request: SshKeyAddRequest,
86 user_agent: Option<String>,
87 ip_addr: Option<String>,
88 request_id: String,
89 ) -> AuthResult<SshKeySummary> {
90 let user_id = self
91 .require_session(&session, Some(request_id.as_str()))
92 .await?;
93 self.add_ssh_key_for_user(user_id, request, user_agent, ip_addr, request_id)
94 .await
95 }
96
97 /// 与 `add_ssh_key` 同逻辑,但调用方已按 RPC 密钥确认过用户身份。
98 pub async fn add_ssh_key_for_user(
99 &self,
100 user_id: i64,
101 request: SshKeyAddRequest,
102 user_agent: Option<String>,
103 ip_addr: Option<String>,
104 request_id: String,
105 ) -> AuthResult<SshKeySummary> {
106 let user = users::Entity::find_by_id(user_id)
107 .one(self.db().read())
108 .await?
109 .ok_or(AuthError::Unauthenticated)?;
110 if user.deleted_at.is_some() || !user.allow_use {
111 return Err(AuthError::Unauthenticated);
112 }
113
114 let name = request.name.trim().to_owned();
115 if name.is_empty() || name.chars().count() > MAX_SSH_KEY_NAME_LEN {
116 return Err(AuthError::SshKeyNameInvalid);
117 }
118 let description = request
119 .description
120 .as_deref()
121 .map(str::trim)
122 .filter(|text| !text.is_empty())
123 .map(str::to_owned);
124 if description
125 .as_ref()
126 .is_some_and(|text| text.chars().count() > MAX_SSH_KEY_DESCRIPTION_LEN)
127 {
128 return Err(AuthError::MalformedRequest);
129 }
130
131 let expiry = request.expires_at;
132 if expiry.is_some_and(|at| at <= Utc::now()) {
133 return Err(AuthError::InvalidExpiry);
134 }
135
136 let public_key = request.public_key.trim().to_owned();
137 let (algorithm, fingerprint) = parse_public_key(&public_key)?;
138
139 let inserted = user_ssh_keys::ActiveModel {
140 user_id: Set(user_id),
141 name: Set(name),
142 description: Set(description),
143 public_key: Set(public_key),
144 algorithm: Set(algorithm),
145 fingerprint: Set(fingerprint),
146 created_at: Set(Utc::now()),
147 expires_at: Set(expiry),
148 ..Default::default()
149 }
150 .insert(self.db().write())
151 .await
152 .map_err(|error| match error.sql_err() {
153 Some(SqlErr::UniqueConstraintViolation(_)) => AuthError::SshKeyTaken,
154 _ => AuthError::Database(crate::db::DbError::from(error)),
155 })?;
156
157 user_events::ActiveModel {
158 user_id: Set(user_id),
159 actor_user_id: Set(Some(user_id)),
160 event_type: Set(UserEventType::PublicKeyAdded),
161 occurred_at: Set(Utc::now()),
162 user_agent: Set(user_agent),
163 trace_id: Set(Some(request_id.clone())),
164 ip_address: Set(ip_addr),
165 details: Set(Some(serde_json::json!({
166 "ssh_key_id": inserted.id,
167 "name": inserted.name,
168 "fingerprint": inserted.fingerprint,
169 }))),
170 ..Default::default()
171 }
172 .insert(self.db().write())
173 .await?;
174
175 let response = summary(inserted);
176 self.emit_labeled(
177 label::AUTH_SSH_KEY_CREATE,
178 Labels::new()
179 .with(label::USER_ID, user_id)
180 .with(label::REQUEST_ID, &request_id),
181 &response,
182 Some(request_id.as_str()),
183 )
184 .await;
185 Ok(response)
186 }
187
188 #[tracing::instrument(skip_all, fields(request_id = %_request_id, client_ip = ?session.client_ip().ok().flatten()))]
189 pub async fn list_ssh_keys(
190 &self,
191 session: Session,
192 query: ListQuery,
193 _request_id: String,
194 ) -> AuthResult<Vec<SshKeySummary>> {
195 let user_id = self
196 .require_session(&session, Some(_request_id.as_str()))
197 .await?;
198 let records = user_ssh_keys::Entity::find()
199 .filter(user_ssh_keys::Column::UserId.eq(user_id))
200 .order_by_desc(user_ssh_keys::Column::CreatedAt)
201 .limit(query.limit())
202 .offset(query.offset())
203 .all(self.db().read())
204 .await?;
205
206 Ok(records.into_iter().map(summary).collect())
207 }
208
209 /// 吊销公钥与提高账号 `security_version` 在同一事务里完成:密钥泄露后,账号既有的访问令牌与
210 #[tracing::instrument(skip_all, fields(request_id = %request_id, client_ip = ?session.client_ip().ok().flatten()))]
211 pub async fn revoke_ssh_key(
212 &self,
213 session: Session,
214 key_id: Uuid,
215 user_agent: Option<String>,
216 ip_addr: Option<String>,
217 request_id: String,
218 ) -> AuthResult<()> {
219 let user_id = self
220 .require_session(&session, Some(request_id.as_str()))
221 .await?;
222 let record = user_ssh_keys::Entity::find_by_id(key_id)
223 .filter(user_ssh_keys::Column::UserId.eq(user_id))
224 .one(self.db().read())
225 .await?
226 .ok_or(AuthError::SshKeyNotFound)?;
227 if record.revoked_at.is_some() {
228 return Ok(());
229 }
230
231 let transaction = self.db().write().begin().await?;
232 let result: AuthResult<()> = async {
233 user_ssh_keys::Entity::update_many()
234 .col_expr(user_ssh_keys::Column::RevokedAt, Expr::value(Utc::now()))
235 .filter(user_ssh_keys::Column::Id.eq(record.id))
236 .exec(&transaction)
237 .await?;
238
239 users::Entity::update_many()
240 .col_expr(
241 users::Column::SecurityVersion,
242 Expr::col(users::Column::SecurityVersion).add(1),
243 )
244 .col_expr(users::Column::UpdatedAt, Expr::value(Utc::now()))
245 .filter(users::Column::Id.eq(user_id))
246 .exec(&transaction)
247 .await?;
248
249 user_events::ActiveModel {
250 user_id: Set(user_id),
251 actor_user_id: Set(Some(user_id)),
252 event_type: Set(UserEventType::PublicKeyRemoved),
253 occurred_at: Set(Utc::now()),
254 user_agent: Set(user_agent),
255 trace_id: Set(Some(request_id.clone())),
256 ip_address: Set(ip_addr),
257 details: Set(Some(serde_json::json!({
258 "ssh_key_id": record.id,
259 "name": record.name,
260 "fingerprint": record.fingerprint,
261 }))),
262 ..Default::default()
263 }
264 .insert(&transaction)
265 .await?;
266
267 Ok(())
268 }
269 .await;
270
271 match result {
272 Ok(()) => transaction.commit().await?,
273 Err(error) => {
274 let _ = transaction.rollback().await;
275 return Err(error);
276 }
277 }
278
279 self.emit_labeled(
280 label::AUTH_SSH_KEY_DELETE,
281 Labels::new()
282 .with(label::USER_ID, user_id)
283 .with(label::REQUEST_ID, &request_id),
284 serde_json::json!({
285 "user_id": user_id,
286 "ssh_key_id": record.id,
287 }),
288 Some(request_id.as_str()),
289 )
290 .await;
291
292 Ok(())
293 }
294}
Deleted lib/auth/src/handlers/identity/ssh_key/types.rs
@@ -1,50 +0,0 @@
1use crate::models::users::user_ssh_keys::PublicKeyType;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use utoipa::ToSchema;
5use uuid::Uuid;
6
7pub const MAX_SSH_KEY_NAME_LEN: usize = 64;
8pub const MAX_SSH_KEY_DESCRIPTION_LEN: usize = 255;
9pub const MIN_SSH_PUBLIC_KEY_LEN: usize = 32;
10pub const MAX_SSH_PUBLIC_KEY_LEN: usize = 8192;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum SshKeyAlgorithm {
15 Rsa,
16 Ed25519,
17 Ecdsa,
18}
19
20impl From<PublicKeyType> for SshKeyAlgorithm {
21 fn from(value: PublicKeyType) -> Self {
22 match value {
23 PublicKeyType::RSA => Self::Rsa,
24 PublicKeyType::ED25519 => Self::Ed25519,
25 PublicKeyType::ECDSA => Self::Ecdsa,
26 }
27 }
28}
29
30#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
31pub struct SshKeyAddRequest {
32 pub name: String,
33 pub description: Option<String>,
34 pub public_key: String,
35 pub expires_at: Option<DateTime<Utc>>,
36}
37
38#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
39pub struct SshKeySummary {
40 pub id: Uuid,
41 pub name: String,
42 pub description: Option<String>,
43 pub algorithm: SshKeyAlgorithm,
44 pub fingerprint: String,
45 pub public_key: String,
46 pub created_at: DateTime<Utc>,
47 pub expires_at: Option<DateTime<Utc>>,
48 pub last_used_at: Option<DateTime<Utc>>,
49 pub revoked_at: Option<DateTime<Utc>>,
50}
Modified lib/auth/OpenApi.Auth.json View lib/auth/OpenApi.Auth.json
@@ -2,7 +2,7 @@
22 "openapi": "3.1.0",
33 "info": {
44 "title": "auth",
5 "description": "认证与账号服务:工作量证明、口令与验证码登录、二次验证、注册、口令重置、访问令牌与 SSH 公钥。",
5 "description": "认证与账号服务:工作量证明、口令与验证码登录、二次验证、注册、口令重置与 SSH 公钥。",
66 "license": {
77 "name": "MIT",
88 "identifier": "MIT"
@@ -10,85 +10,37 @@
1010 "version": "1.0.0"
1111 },
1212 "paths": {
13 "/api/v1/oauth/access-tokens": {
14 "get": {
13 "/api/v1/auth/challenge": {
14 "post": {
1515 "tags": [
1616 "auth"
17 ],
18 "summary": "列示访问令牌",
19 "description": "按创建时间倒序返回当前账号的令牌摘要,只给指纹与状态,不回吐令牌原文。分页参数由 `page` / `page_size` 控制。查询串不是合法分页参数或路径/方法不匹配时返回 400 / 404 由 actix-web 直接给出。",
20 "operationId": "auth_access_token_list",
21 "parameters": [
22 {
23 "name": "page",
24 "in": "query",
25 "description": "页码,从 1 开始;默认 1",
26 "required": false,
27 "schema": {
28 "type": "integer",
29 "format": "int64",
30 "minimum": 0
31 }
32 },
33 {
34 "name": "page_size",
35 "in": "query",
36 "description": "每页条数,默认 20,最多 100",
37 "required": false,
38 "schema": {
39 "type": "integer",
40 "format": "int64",
41 "minimum": 0
42 }
43 }
4417 ],
18 "summary": "签发工作量证明挑战",
19 "description": "把挑战写进当前会话并返回。客户端需要为 `context` 找出一枚满足 `difficulty`(前导零比特数)的 nonce;`pow.v1` 算法与前端求解器一致。",
20 "operationId": "auth_challenge",
4521 "responses": {
4622 "200": {
47 "description": "令牌摘要列表",
23 "description": "挑战已签发并写入会话",
4824 "content": {
4925 "application/json": {
5026 "schema": {
51 "$ref": "#/components/schemas/Envelope_Vec_AccessTokenSummary"
27 "$ref": "#/components/schemas/Envelope_ChallengeResponse"
5228 },
5329 "example": {
5430 "code": 200,
55 "data": [
56 {
57 "created_at": "2026-01-02T03:04:05Z",
58 "description": "本机命令行",
59 "expires_at": null,
60 "fingerprint": "3f9c1a7b2d4e6058",
61 "id": "018f2b1c-4d5e-7f80-9a1b-2c3d4e5f6071",
62 "last_used_at": null,
63 "name": "cli",
64 "revoked_at": null,
65 "scope": [
66 "ssh_key",
67 "assets"
68 ]
69 }
70 ],
31 "data": {
32 "context": "3xJk9Qp2mV7sLbT1rZa8nHc4wDf6YgU0",
33 "difficulty": 16,
34 "expires_at": 1767139500,
35 "issued_at": 1767139200
36 },
7137 "message": "success"
7238 }
7339 }
7440 }
7541 },
76 "401": {
77 "description": "会话未绑定账号(`unauthenticated`)",
78 "content": {
79 "application/json": {
80 "schema": {
81 "$ref": "#/components/schemas/Envelope_NoData"
82 },
83 "example": {
84 "code": "unauthenticated",
85 "message": "authentication required"
86 }
87 }
88 }
89 },
9042 "500": {
91 "description": "服务端故障(`internal`、`database`、`session`、`serialization`)",
43 "description": "`internal`(挑战无法写入会话)/ `cache` / `session` / `serialization`",
9244 "content": {
9345 "application/json": {
9446 "schema": {
@@ -102,19 +54,21 @@
10254 }
10355 }
10456 }
105 },
57 }
58 },
59 "/api/v1/auth/login": {
10660 "post": {
10761 "tags": [
10862 "auth"
10963 ],
110 "summary": "签发访问令牌",
111 "description": "名称 1~64 字符、备注最长 255 字符,`scope` 必须全部命中白名单(最多 32 项),`expires_in_secs` 取值 60 ~ 31536000(不填表示长期有效)。同一账号未吊销的令牌上限 50 个。签发成功记 `AccessKeyAdded` 审计事件。",
112 "operationId": "auth_access_token_create",
64 "summary": "口令登录",
65 "description": "用口令登录。`identity` 可以是用户名、邮箱(大小写不敏感)或用户编号,`password_hash` 是客户端先算好的口令摘要,工作量证明的载荷必须是 `identity`。账号启用了多因子时返回 `mfa_required: true` 与 `challenge`,此时会话尚未绑定,需再调 `/api/v1/auth/login/verify`。",
66 "operationId": "auth_login",
11367 "requestBody": {
11468 "content": {
11569 "application/json": {
11670 "schema": {
117 "$ref": "#/components/schemas/AccessTokenCreateRequest"
71 "$ref": "#/components/schemas/UserLoginRequest"
11872 }
11973 }
12074 },
@@ -122,24 +76,20 @@
12276 },
12377 "responses": {
12478 "200": {
125 "description": "令牌已签发,`token` 只在此响应里出现一次",
79 "description": "登录成功;`mfa_required` 为真时还需二次验证",
12680 "content": {
12781 "application/json": {
12882 "schema": {
129 "$ref": "#/components/schemas/Envelope_AccessTokenCreateResponse"
83 "$ref": "#/components/schemas/Envelope_UserLoginResponse"
13084 },
13185 "example": {
13286 "code": 200,
13387 "data": {
134 "created_at": "2026-01-02T03:04:05Z",
135 "expires_at": null,
136 "id": "018f2b1c-4d5e-7f80-9a1b-2c3d4e5f6071",
137 "name": "cli",
138 "scope": [
139 "ssh_key",
140 "assets"
141 ],
142 "token": "Zk9tQ2h5cFJ2TjB3U3gxbUhhcTRkRmc2WXpVMEJqTDlLbVNu"
88 "challenge": null,
89 "displayname": "Ada",
90 "mfa_required": false,
91 "user_id": 1024,
92 "username": "ada"
14393 },
14494 "message": "success"
14595 }
@@ -147,116 +97,63 @@
14797 }
14898 },
14999 "400": {
150 "description": "名称、备注、范围或有效期不合法(`invalid_access_key_name`、`malformed_request`、`invalid_scope`、`invalid_expiry`)",
100 "description": "工作量证明过期、载荷不匹配或请求不合法(`expired`、`invalid_proof`、`invalid_payload`、`malformed_request`)",
151101 "content": {
152102 "application/json": {
153103 "schema": {
154104 "$ref": "#/components/schemas/Envelope_NoData"
155105 },
156106 "example": {
157 "code": "invalid_scope",
158 "message": "access token scope is invalid"
107 "code": "expired",
108 "message": "challenge expired"
159109 }
160110 }
161111 }
162112 },
163113 "401": {
164 "description": "会话未绑定账号,或账号已删除 / 不允许使用(`unauthenticated`)",
114 "description": "身份或口令不正确(`invalid_credentials`)",
165115 "content": {
166116 "application/json": {
167117 "schema": {
168118 "$ref": "#/components/schemas/Envelope_NoData"
169119 },
170120 "example": {
171 "code": "unauthenticated",
172 "message": "authentication required"
121 "code": "invalid_credentials",
122 "message": "invalid credentials"
173123 }
174124 }
175125 }
176126 },
177 "429": {
178 "description": "未吊销的令牌已达上限(`too_many_access_keys`)",
127 "403": {
128 "description": "账号被禁止登录,原因见 `message`(`login_forbidden`)",
179129 "content": {
180130 "application/json": {
181131 "schema": {
182132 "$ref": "#/components/schemas/Envelope_NoData"
183133 },
184134 "example": {
185 "code": "too_many_access_keys",
186 "message": "too many access keys"
135 "code": "login_forbidden",
136 "message": "account is banned"
187137 }
188138 }
189139 }
190140 },
191 "500": {
192 "description": "服务端故障(`internal`、`database`、`session`、`serialization`)",
141 "429": {
142 "description": "失败次数超出配额(`rate_limited`)",
193143 "content": {
194144 "application/json": {
195145 "schema": {
196146 "$ref": "#/components/schemas/Envelope_NoData"
197147 },
198148 "example": {
199 "code": "internal",
200 "message": "internal server error"
201 }
202 }
203 }
204 }
205 }
206 }
207 },
208 "/api/v1/oauth/access-tokens/{token_id}": {
209 "delete": {
210 "tags": [
211 "auth"
212 ],
213 "summary": "吊销访问令牌",
214 "description": "把令牌标记为已吊销,记录保留不硬删;重复调用幂等。令牌不存在或不属于当前账号返回 `access_token_not_found`(按 `errors.rs` 的归类为 401)。吊销成功记 `AccessKeyRemoved` 审计事件。",
215 "operationId": "auth_access_token_revoke",
216 "parameters": [
217 {
218 "name": "token_id",
219 "in": "path",
220 "description": "令牌编号",
221 "required": true,
222 "schema": {
223 "type": "string",
224 "format": "uuid"
225 }
226 }
227 ],
228 "responses": {
229 "200": {
230 "description": "已吊销;重复调用同样返回成功",
231 "content": {
232 "application/json": {
233 "schema": {
234 "$ref": "#/components/schemas/Envelope_NoData"
235 },
236 "example": {
237 "code": 200,
238 "data": null,
239 "message": "success"
240 }
241 }
242 }
243 },
244 "401": {
245 "description": "会话未绑定账号(`unauthenticated`),或令牌不属于当前账号 / 不存在(`access_token_not_found`)",
246 "content": {
247 "application/json": {
248 "schema": {
249 "$ref": "#/components/schemas/Envelope_NoData"
250 },
251 "example": {
252 "code": "access_token_not_found",
253 "message": "access token does not exist"
149 "code": "rate_limited",
150 "message": "too many attempts"
254151 }
255152 }
256153 }
257154 },
258155 "500": {
259 "description": "服务端故障(`internal`、`database`、`session`、`serialization`)",
156 "description": "服务端故障(`internal`、`database`、`cache`、`session`、`serialization`)",
260157 "content": {
261158 "application/json": {
262159 "schema": {
@@ -272,19 +169,19 @@
272169 }
273170 }
274171 },
275 "/api/v1/oauth/app-tokens": {
172 "/api/v1/auth/login/code": {
276173 "post": {
277174 "tags": [
278175 "auth"
279176 ],
280 "summary": "签发内部应用令牌",
281 "description": "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 为目标 app 的 audience,`client_id` 为 app 标识。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。会话未绑定账号返回 `unauthenticated`(401);app 未在 `apps` 清单里返回 `app_not_found`(404);私钥未配置返回 `unavailable`(503)。",
282 "operationId": "auth_app_token_issue",
177 "summary": "发送邮箱登录验证码",
178 "description": "向账号邮箱发送 6 位登录验证码,`expires_in_secs` 为有效期。工作量证明的载荷必须是 `email`。同一账号的发码间隔受 `email_code_rate_limit_secs` 限制。",
179 "operationId": "auth_login_code",
283180 "requestBody": {
284181 "content": {
285182 "application/json": {
286183 "schema": {
287 "$ref": "#/components/schemas/AppTokenRequest"
184 "$ref": "#/components/schemas/LoginCodeRequest"
288185 }
289186 }
290187 },
@@ -292,21 +189,16 @@
292189 },
293190 "responses": {
294191 "200": {
295 "description": "令牌已签发,`token` 是 EdDSA JWT",
192 "description": "验证码已发送",
296193 "content": {
297194 "application/json": {
298195 "schema": {
299 "$ref": "#/components/schemas/Envelope_AppTokenResponse"
196 "$ref": "#/components/schemas/Envelope_LoginCodeResponse"
300197 },
301198 "example": {
302199 "code": 200,
303200 "data": {
304 "app_id": "api",
305 "audience": "api",
306 "expires_at": "2026-01-02T03:19:05Z",
307 "issued_at": "2026-01-02T03:04:05Z",
308 "key_id": "app-local-1",
309 "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImsxIiwidHlwIjoiSldUIn0.…"
201 "expires_in_secs": 600
310202 },
311203 "message": "success"
312204 }
@@ -314,109 +206,63 @@
314206 }
315207 },
316208 "400": {
317 "description": "app_id 为空或 ttl_seconds 超出上限(`malformed_request`、`invalid_expiry`)",
209 "description": "邮箱格式不合法或工作量证明过期、载荷不匹配(`invalid_email`、`expired`、`invalid_proof`、`invalid_payload`、`malformed_request`)",
318210 "content": {
319211 "application/json": {
320212 "schema": {
321213 "$ref": "#/components/schemas/Envelope_NoData"
322214 },
323215 "example": {
324 "code": "invalid_expiry",
325 "message": "expires_at is invalid"
216 "code": "invalid_email",
217 "message": "invalid email address"
326218 }
327219 }
328220 }
329221 },
330222 "401": {
331 "description": "会话未绑定账号(`unauthenticated`)",
223 "description": "邮箱未注册或账号不可用(`invalid_credentials`)",
332224 "content": {
333225 "application/json": {
334226 "schema": {
335227 "$ref": "#/components/schemas/Envelope_NoData"
336228 },
337229 "example": {
338 "code": "unauthenticated",
339 "message": "authentication required"
230 "code": "invalid_credentials",
231 "message": "invalid credentials"
340232 }
341233 }
342234 }
343235 },
344 "404": {
345 "description": "app_id 不在认证中心的 `apps` 清单里(`app_not_found`)",
236 "403": {
237 "description": "账号被禁止登录,原因见 `message`(`login_forbidden`)",
346238 "content": {
347239 "application/json": {
348240 "schema": {
349241 "$ref": "#/components/schemas/Envelope_NoData"
350242 },
351243 "example": {
352 "code": "app_not_found",
353 "message": "app does not exist"
244 "code": "login_forbidden",
245 "message": "account is banned"
354246 }
355247 }
356248 }
357249 },
358 "500": {
359 "description": "服务端故障(`internal`、`serialization`)",
250 "429": {
251 "description": "发码过于频繁(`rate_limited`)",
360252 "content": {
361253 "application/json": {
362254 "schema": {
363255 "$ref": "#/components/schemas/Envelope_NoData"
364256 },
365257 "example": {
366 "code": "internal",
367 "message": "internal server error"
368 }
369 }
370 }
371 },
372 "503": {
373 "description": "签名密钥未配置(`unavailable`)",
374 "content": {
375 "application/json": {
376 "schema": {
377 "$ref": "#/components/schemas/Envelope_NoData"
378 },
379 "example": {
380 "code": "unavailable",
381 "message": "dependency is unavailable: jwt.private_key is required to issue tokens"
382 }
383 }
384 }
385 }
386 }
387 }
388 },
389 "/api/v1/oauth/challenge": {
390 "post": {
391 "tags": [
392 "auth"
393 ],
394 "summary": "签发工作量证明挑战",
395 "description": "把挑战写进当前会话并返回。客户端需要为 `context` 找出一枚满足 `difficulty`(前导零比特数)的 nonce;`pow.v1` 算法与前端求解器一致。",
396 "operationId": "auth_challenge",
397 "responses": {
398 "200": {
399 "description": "挑战已签发并写入会话",
400 "content": {
401 "application/json": {
402 "schema": {
403 "$ref": "#/components/schemas/Envelope_ChallengeResponse"
404 },
405 "example": {
406 "code": 200,
407 "data": {
408 "context": "3xJk9Qp2mV7sLbT1rZa8nHc4wDf6YgU0",
409 "difficulty": 16,
410 "expires_at": 1767139500,
411 "issued_at": 1767139200
412 },
413 "message": "success"
258 "code": "rate_limited",
259 "message": "too many requests"
414260 }
415261 }
416262 }
417263 },
418264 "500": {
419 "description": "`internal`(挑战无法写入会话)/ `cache` / `session` / `serialization`",
265 "description": "服务端故障,含邮件发送失败(`internal`、`database`、`cache`、`session`、`email`、`serialization`)",
420266 "content": {
421267 "application/json": {
422268 "schema": {
@@ -432,19 +278,19 @@
432278 }
433279 }
434280 },
435 "/api/v1/oauth/login": {
281 "/api/v1/auth/login/code/verify": {
436282 "post": {
437283 "tags": [
438284 "auth"
439285 ],
440 "summary": "口令登录",
441 "description": "用口令登录。`identity` 可以是用户名、邮箱(大小写不敏感)或用户编号,`password_hash` 是客户端先算好的口令摘要,工作量证明的载荷必须是 `identity`。账号启用了多因子时返回 `mfa_required: true` 与 `challenge`,此时会话尚未绑定,需再调 `/api/v1/oauth/login/verify`。",
442 "operationId": "auth_login",
286 "summary": "邮箱验证码换会话",
287 "description": "用邮箱登录验证码换会话。工作量证明的载荷必须是 `email`。验证码一旦核对通过即作废,连续输错 `max_verify_attempts` 次后需重新申请。账号启用了多因子时返回 `mfa_required: true` 与 `challenge`。",
288 "operationId": "auth_login_code_verify",
443289 "requestBody": {
444290 "content": {
445291 "application/json": {
446292 "schema": {
447 "$ref": "#/components/schemas/UserLoginRequest"
293 "$ref": "#/components/schemas/LoginCodeVerifyRequest"
448294 }
449295 }
450296 },
@@ -473,21 +319,21 @@
473319 }
474320 },
475321 "400": {
476 "description": "工作量证明过期、载荷不匹配或请求不合法(`expired`、`invalid_proof`、`invalid_payload`、`malformed_request`)",
322 "description": "验证码不正确或已过期,或邮箱格式、工作量证明不合法(`invalid_email_code`、`invalid_email`、`expired`、`invalid_proof`、`invalid_payload`、`malformed_request`)",
477323 "content": {
478324 "application/json": {
479325 "schema": {
480326 "$ref": "#/components/schemas/Envelope_NoData"
481327 },
482328 "example": {
483 "code": "expired",
484 "message": "challenge expired"
329 "code": "invalid_email_code",
330 "message": "invalid email code"
485331 }
486332 }
487333 }
488334 },
489335 "401": {
490 "description": "身份或口令不正确(`invalid_credentials`)",
336 "description": "邮箱未注册或账号不可用(`invalid_credentials`)",
491337 "content": {
492338 "application/json": {
493339 "schema": {
@@ -515,14 +361,14 @@
515361 }
516362 },
517363 "429": {
518 "description": "失败次数超出配额(`rate_limited`)",
364 "description": "验证码尝试次数超限或请求过频(`email_code_attempts_exceeded`、`rate_limited`)",
519365 "content": {
520366 "application/json": {
521367 "schema": {
522368 "$ref": "#/components/schemas/Envelope_NoData"
523369 },
524370 "example": {
525 "code": "rate_limited",
371 "code": "email_code_attempts_exceeded",
526372 "message": "too many attempts"
527373 }
528374 }
@@ -545,19 +391,19 @@
545391 }
546392 }
547393 },
548 "/api/v1/oauth/login/code": {
394 "/api/v1/auth/login/verify": {
549395 "post": {
550396 "tags": [
551397 "auth"
552398 ],
553 "summary": "发送邮箱登录验证码",
554 "description": "向账号邮箱发送 6 位登录验证码,`expires_in_secs` 为有效期。工作量证明的载荷必须是 `email`。同一账号的发码间隔受 `email_code_rate_limit_secs` 限制。",
555 "operationId": "auth_login_code",
399 "summary": "二次验证换会话",
400 "description": "用 6 位 TOTP 动态码或一枚未使用的恢复码完成二次验证并绑定会话。`challenge` 必须与 `/api/v1/auth/login` 或 `/api/v1/auth/login/code/verify` 返回的一致,挑战有效期 300 秒,同一动态码时间步不能重复使用,恢复码用掉即作废。",
401 "operationId": "auth_login_verify",
556402 "requestBody": {
557403 "content": {
558404 "application/json": {
559405 "schema": {
560 "$ref": "#/components/schemas/LoginCodeRequest"
406 "$ref": "#/components/schemas/LoginVerifyRequest"
561407 }
562408 }
563409 },
@@ -565,16 +411,20 @@
565411 },
566412 "responses": {
567413 "200": {
568 "description": "验证码已发送",
414 "description": "登录成功,会话已绑定",
569415 "content": {
570416 "application/json": {
571417 "schema": {
572 "$ref": "#/components/schemas/Envelope_LoginCodeResponse"
418 "$ref": "#/components/schemas/Envelope_UserLoginResponse"
573419 },
574420 "example": {
575421 "code": 200,
576422 "data": {
577 "expires_in_secs": 600
423 "challenge": null,
424 "displayname": "Ada",
425 "mfa_required": false,
426 "user_id": 1024,
427 "username": "ada"
578428 },
579429 "message": "success"
580430 }
@@ -582,241 +432,15 @@
582432 }
583433 },
584434 "400": {
585 "description": "邮箱格式不合法或工作量证明过期、载荷不匹配(`invalid_email`、`expired`、`invalid_proof`、`invalid_payload`、`malformed_request`)",
435 "description": "挑战过期或对不上、动态码错误、动态码时间步已用过、恢复码无效(`expired`、`malformed_request`、`totp_code_invalid`、`totp_step_reused`、`recovery_code_invalid`)",
586436 "content": {
587437 "application/json": {
588438 "schema": {
589439 "$ref": "#/components/schemas/Envelope_NoData"
590440 },
591441 "example": {
592 "code": "invalid_email",
593 "message": "invalid email address"
594 }
595 }
596 }
597 },
598 "401": {
599 "description": "邮箱未注册或账号不可用(`invalid_credentials`)",
600 "content": {
601 "application/json": {
602 "schema": {
603 "$ref": "#/components/schemas/Envelope_NoData"
604 },
605 "example": {
606 "code": "invalid_credentials",
607 "message": "invalid credentials"
608 }
609 }
610 }
611 },
612 "403": {
613 "description": "账号被禁止登录,原因见 `message`(`login_forbidden`)",
614 "content": {
615 "application/json": {
616 "schema": {
617 "$ref": "#/components/schemas/Envelope_NoData"
618 },
619 "example": {
620 "code": "login_forbidden",
621 "message": "account is banned"
622 }
623 }
624 }
625 },
626 "429": {
627 "description": "发码过于频繁(`rate_limited`)",
628 "content": {
629 "application/json": {
630 "schema": {
631 "$ref": "#/components/schemas/Envelope_NoData"
632 },
633 "example": {
634 "code": "rate_limited",
635 "message": "too many requests"
636 }
637 }
638 }
639 },
640 "500": {
641 "description": "服务端故障,含邮件发送失败(`internal`、`database`、`cache`、`session`、`email`、`serialization`)",
642 "content": {
643 "application/json": {
644 "schema": {
645 "$ref": "#/components/schemas/Envelope_NoData"
646 },
647 "example": {
648 "code": "internal",
649 "message": "internal server error"
650 }
651 }
652 }
653 }
654 }
655 }
656 },
657 "/api/v1/oauth/login/code/verify": {
658 "post": {
659 "tags": [
660 "auth"
661 ],
662 "summary": "邮箱验证码换会话",
663 "description": "用邮箱登录验证码换会话。工作量证明的载荷必须是 `email`。验证码一旦核对通过即作废,连续输错 `max_verify_attempts` 次后需重新申请。账号启用了多因子时返回 `mfa_required: true` 与 `challenge`。",
664 "operationId": "auth_login_code_verify",
665 "requestBody": {
666 "content": {
667 "application/json": {
668 "schema": {
669 "$ref": "#/components/schemas/LoginCodeVerifyRequest"
670 }
671 }
672 },
673 "required": true
674 },
675 "responses": {
676 "200": {
677 "description": "登录成功;`mfa_required` 为真时还需二次验证",
678 "content": {
679 "application/json": {
680 "schema": {
681 "$ref": "#/components/schemas/Envelope_UserLoginResponse"
682 },
683 "example": {
684 "code": 200,
685 "data": {
686 "challenge": null,
687 "displayname": "Ada",
688 "mfa_required": false,
689 "user_id": 1024,
690 "username": "ada"
691 },
692 "message": "success"
693 }
694 }
695 }
696 },
697 "400": {
698 "description": "验证码不正确或已过期,或邮箱格式、工作量证明不合法(`invalid_email_code`、`invalid_email`、`expired`、`invalid_proof`、`invalid_payload`、`malformed_request`)",
699 "content": {
700 "application/json": {
701 "schema": {
702 "$ref": "#/components/schemas/Envelope_NoData"
703 },
704 "example": {
705 "code": "invalid_email_code",
706 "message": "invalid email code"
707 }
708 }
709 }
710 },
711 "401": {
712 "description": "邮箱未注册或账号不可用(`invalid_credentials`)",
713 "content": {
714 "application/json": {
715 "schema": {
716 "$ref": "#/components/schemas/Envelope_NoData"
717 },
718 "example": {
719 "code": "invalid_credentials",
720 "message": "invalid credentials"
721 }
722 }
723 }
724 },
725 "403": {
726 "description": "账号被禁止登录,原因见 `message`(`login_forbidden`)",
727 "content": {
728 "application/json": {
729 "schema": {
730 "$ref": "#/components/schemas/Envelope_NoData"
731 },
732 "example": {
733 "code": "login_forbidden",
734 "message": "account is banned"
735 }
736 }
737 }
738 },
739 "429": {
740 "description": "验证码尝试次数超限或请求过频(`email_code_attempts_exceeded`、`rate_limited`)",
741 "content": {
742 "application/json": {
743 "schema": {
744 "$ref": "#/components/schemas/Envelope_NoData"
745 },
746 "example": {
747 "code": "email_code_attempts_exceeded",
748 "message": "too many attempts"
749 }
750 }
751 }
752 },
753 "500": {
754 "description": "服务端故障(`internal`、`database`、`cache`、`session`、`serialization`)",
755 "content": {
756 "application/json": {
757 "schema": {
758 "$ref": "#/components/schemas/Envelope_NoData"
759 },
760 "example": {
761 "code": "internal",
762 "message": "internal server error"
763 }
764 }
765 }
766 }
767 }
768 }
769 },
770 "/api/v1/oauth/login/verify": {
771 "post": {
772 "tags": [
773 "auth"
774 ],
775 "summary": "二次验证换会话",
776 "description": "用 6 位 TOTP 动态码或一枚未使用的恢复码完成二次验证并绑定会话。`challenge` 必须与 `/api/v1/oauth/login` 或 `/api/v1/oauth/login/code/verify` 返回的一致,挑战有效期 300 秒,同一动态码时间步不能重复使用,恢复码用掉即作废。",
777 "operationId": "auth_login_verify",
778 "requestBody": {
779 "content": {
780 "application/json": {
781 "schema": {
782 "$ref": "#/components/schemas/LoginVerifyRequest"
783 }
784 }
785 },
786 "required": true
787 },
788 "responses": {
789 "200": {
790 "description": "登录成功,会话已绑定",
791 "content": {
792 "application/json": {
793 "schema": {
794 "$ref": "#/components/schemas/Envelope_UserLoginResponse"
795 },
796 "example": {
797 "code": 200,
798 "data": {
799 "challenge": null,
800 "displayname": "Ada",
801 "mfa_required": false,
802 "user_id": 1024,
803 "username": "ada"
804 },
805 "message": "success"
806 }
807 }
808 }
809 },
810 "400": {
811 "description": "挑战过期或对不上、动态码错误、动态码时间步已用过、恢复码无效(`expired`、`malformed_request`、`totp_code_invalid`、`totp_step_reused`、`recovery_code_invalid`)",
812 "content": {
813 "application/json": {
814 "schema": {
815 "$ref": "#/components/schemas/Envelope_NoData"
816 },
817 "example": {
818 "code": "totp_code_invalid",
819 "message": "invalid totp code"
442 "code": "totp_code_invalid",
443 "message": "invalid totp code"
820444 }
821445 }
822446 }
@@ -880,13 +504,13 @@
880504 }
881505 }
882506 },
883 "/api/v1/oauth/me": {
507 "/api/v1/auth/me": {
884508 "get": {
885509 "tags": [
886510 "auth"
887511 ],
888512 "summary": "当前登录用户",
889 "description": "通过会话 Cookie 或 Bearer 访问令牌返回用户自述信息,含头像地址。Bearer 令牌需具备 profile、email、avatar 权限,并校验账号与会话状态。结果在服务端缓存 300 秒,每第 5 次访问回源刷新;登出与改密会主动失效该缓存。",
513 "description": "通过会话 Cookie 返回用户自述信息。结果在服务端缓存 300 秒,每第 5 次访问回源刷新;登出与改密会主动失效该缓存。",
890514 "operationId": "auth_me",
891515 "responses": {
892516 "200": {
@@ -928,16 +552,6 @@
928552 }
929553 }
930554 },
931 "403": {
932 "description": "访问令牌权限不足(`scope_insufficient`)",
933 "content": {
934 "application/json": {
935 "schema": {
936 "$ref": "#/components/schemas/Envelope_NoData"
937 }
938 }
939 }
940 },
941555 "500": {
942556 "description": "服务端故障(`internal`、`database`、`cache`、`session`、`serialization`)",
943557 "content": {
@@ -955,7 +569,7 @@
955569 }
956570 }
957571 },
958 "/api/v1/oauth/password/reset": {
572 "/api/v1/auth/password/reset": {
959573 "post": {
960574 "tags": [
961575 "auth"
@@ -1036,7 +650,7 @@
1036650 }
1037651 }
1038652 },
1039 "/api/v1/oauth/password/reset/confirm": {
653 "/api/v1/auth/password/reset/confirm": {
1040654 "post": {
1041655 "tags": [
1042656 "auth"
@@ -1117,7 +731,7 @@
1117731 }
1118732 }
1119733 },
1120 "/api/v1/oauth/register": {
734 "/api/v1/auth/register": {
1121735 "post": {
1122736 "tags": [
1123737 "auth"
@@ -1228,7 +842,7 @@
1228842 }
1229843 }
1230844 },
1231 "/api/v1/oauth/register/code": {
845 "/api/v1/auth/register/code": {
1232846 "post": {
1233847 "tags": [
1234848 "auth"
@@ -1323,13 +937,13 @@
1323937 }
1324938 }
1325939 },
1326 "/api/v1/oauth/session/logout": {
940 "/api/v1/auth/session/logout": {
1327941 "post": {
1328942 "tags": [
1329943 "auth"
1330944 ],
1331945 "summary": "登出",
1332 "description": "销毁当前会话,并清掉该用户的 `/api/v1/oauth/me` 缓存。匿名调用同样成功,此时 `user_id` 为 `null`。",
946 "description": "销毁当前会话,并清掉该用户的 `/api/v1/auth/me` 缓存。匿名调用同样成功,此时 `user_id` 为 `null`。",
1333947 "operationId": "auth_logout",
1334948 "responses": {
1335949 "200": {
@@ -1366,86 +980,24 @@
1366980 }
1367981 }
1368982 },
1369 "/api/v1/oauth/session/token": {
1370 "post": {
983 "/api/v1/auth/ssh-keys": {
984 "get": {
1371985 "tags": [
1372986 "auth"
1373987 ],
1374 "summary": "用会话换取平台访问令牌",
1375 "description": "认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 固定为 `gitdata.ai`,`azp`(`app_id`)可选。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。",
1376 "operationId": "auth_token_issue",
1377 "requestBody": {
1378 "content": {
1379 "application/json": {
1380 "schema": {
1381 "$ref": "#/components/schemas/TokenRequest"
1382 }
1383 }
1384 },
1385 "required": true
1386 },
1387 "responses": {
1388 "200": {
1389 "description": "令牌已签发",
1390 "content": {
1391 "application/json": {
1392 "schema": {
1393 "$ref": "#/components/schemas/Envelope_TokenResponse"
1394 }
1395 }
1396 }
1397 },
1398 "400": {
1399 "description": "ttl 超出上限(`invalid_expiry`)",
1400 "content": {
1401 "application/json": {
1402 "schema": {
1403 "$ref": "#/components/schemas/Envelope_NoData"
1404 }
1405 }
1406 }
1407 },
1408 "401": {
1409 "description": "会话未绑定账号(`unauthenticated`)",
1410 "content": {
1411 "application/json": {
1412 "schema": {
1413 "$ref": "#/components/schemas/Envelope_NoData"
1414 }
1415 }
1416 }
1417 },
1418 "404": {
1419 "description": "app_id 不在清单里(`app_not_found`)",
1420 "content": {
1421 "application/json": {
1422 "schema": {
1423 "$ref": "#/components/schemas/Envelope_NoData"
1424 }
1425 }
1426 }
1427 }
1428 }
1429 }
1430 },
1431 "/api/v1/oauth/ssh-keys": {
1432 "get": {
1433 "tags": [
1434 "auth"
1435 ],
1436 "summary": "列示 SSH 公钥",
1437 "description": "按创建时间倒序返回当前账号的公钥,含已吊销与已过期的记录。分页参数由 `page` / `page_size` 控制。查询串不是合法分页参数时由 actix-web 直接返回 400。",
1438 "operationId": "auth_ssh_key_list",
1439 "parameters": [
1440 {
1441 "name": "page",
1442 "in": "query",
1443 "description": "页码,从 1 开始;默认 1",
1444 "required": false,
1445 "schema": {
1446 "type": "integer",
1447 "format": "int64",
1448 "minimum": 0
988 "summary": "列示 SSH 公钥",
989 "description": "按创建时间倒序返回当前账号的公钥,含已吊销与已过期的记录。分页参数由 `page` / `page_size` 控制。查询串不是合法分页参数时由 actix-web 直接返回 400。",
990 "operationId": "auth_ssh_key_list",
991 "parameters": [
992 {
993 "name": "page",
994 "in": "query",
995 "description": "页码,从 1 开始;默认 1",
996 "required": false,
997 "schema": {
998 "type": "integer",
999 "format": "int64",
1000 "minimum": 0
14491001 }
14501002 },
14511003 {
@@ -1622,7 +1174,7 @@
16221174 }
16231175 }
16241176 },
1625 "/api/v1/oauth/ssh-keys/{key_id}": {
1177 "/api/v1/auth/ssh-keys/{key_id}": {
16261178 "delete": {
16271179 "tags": [
16281180 "auth"
@@ -3827,195 +3379,10 @@
38273379 }
38283380 }
38293381 }
3830 },
3831 "/health": {
3832 "get": {
3833 "tags": [
3834 "health"
3835 ],
3836 "summary": "存活探针",
3837 "description": "只回答进程是否还在处理请求,不触碰任何外部依赖:数据库、Redis、RocketMQ 全都不可用时依然返回 200。用于区分「进程死了」和「依赖挂了」。",
3838 "operationId": "health_live",
3839 "responses": {
3840 "200": {
3841 "description": "进程存活",
3842 "content": {
3843 "application/json": {
3844 "schema": {
3845 "$ref": "#/components/schemas/Envelope_HealthResponse"
3846 }
3847 }
3848 }
3849 }
3850 }
3851 }
3852 },
3853 "/ready": {
3854 "get": {
3855 "tags": [
3856 "health"
3857 ],
3858 "summary": "就绪探针",
3859 "description": "并发探测启动时必须连上的三个依赖——PostgreSQL、Redis、RocketMQ,全部可用返回 200,任一不可用返回 503 并在 `checks` 里标出是哪一项。单项探测上限 2 秒,失败细节只写日志,不进响应体。",
3860 "operationId": "health_ready",
3861 "responses": {
3862 "200": {
3863 "description": "全部依赖可用",
3864 "content": {
3865 "application/json": {
3866 "schema": {
3867 "$ref": "#/components/schemas/Envelope_ReadinessResponse"
3868 }
3869 }
3870 }
3871 },
3872 "503": {
3873 "description": "至少一个依赖不可用(`unavailable`)",
3874 "content": {
3875 "application/json": {
3876 "schema": {
3877 "$ref": "#/components/schemas/Envelope_ReadinessResponse"
3878 }
3879 }
3880 }
3881 }
3882 }
3883 }
38843382 }
38853383 },
38863384 "components": {
38873385 "schemas": {
3888 "AccessTokenCreateRequest": {
3889 "type": "object",
3890 "required": [
3891 "name",
3892 "scope"
3893 ],
3894 "properties": {
3895 "description": {
3896 "type": [
3897 "string",
3898 "null"
3899 ]
3900 },
3901 "expires_in_secs": {
3902 "type": [
3903 "integer",
3904 "null"
3905 ],
3906 "format": "int64"
3907 },
3908 "name": {
3909 "type": "string"
3910 },
3911 "scope": {
3912 "type": "array",
3913 "items": {
3914 "type": "string"
3915 }
3916 }
3917 }
3918 },
3919 "AccessTokenCreateResponse": {
3920 "type": "object",
3921 "required": [
3922 "id",
3923 "name",
3924 "token",
3925 "scope",
3926 "created_at"
3927 ],
3928 "properties": {
3929 "created_at": {
3930 "type": "string",
3931 "format": "date-time"
3932 },
3933 "expires_at": {
3934 "type": [
3935 "string",
3936 "null"
3937 ],
3938 "format": "date-time"
3939 },
3940 "id": {
3941 "type": "string",
3942 "format": "uuid"
3943 },
3944 "name": {
3945 "type": "string"
3946 },
3947 "scope": {
3948 "type": "array",
3949 "items": {
3950 "type": "string"
3951 }
3952 },
3953 "token": {
3954 "type": "string"
3955 }
3956 }
3957 },
3958 "AccessTokenSummary": {
3959 "type": "object",
3960 "required": [
3961 "id",
3962 "name",
3963 "scope",
3964 "created_at"
3965 ],
3966 "properties": {
3967 "created_at": {
3968 "type": "string",
3969 "format": "date-time"
3970 },
3971 "description": {
3972 "type": [
3973 "string",
3974 "null"
3975 ]
3976 },
3977 "expires_at": {
3978 "type": [
3979 "string",
3980 "null"
3981 ],
3982 "format": "date-time"
3983 },
3984 "fingerprint": {
3985 "type": [
3986 "string",
3987 "null"
3988 ]
3989 },
3990 "id": {
3991 "type": "string",
3992 "format": "uuid"
3993 },
3994 "last_used_at": {
3995 "type": [
3996 "string",
3997 "null"
3998 ],
3999 "format": "date-time"
4000 },
4001 "name": {
4002 "type": "string"
4003 },
4004 "revoked_at": {
4005 "type": [
4006 "string",
4007 "null"
4008 ],
4009 "format": "date-time"
4010 },
4011 "scope": {
4012 "type": "array",
4013 "items": {
4014 "type": "string"
4015 }
4016 }
4017 }
4018 },
40193386 "AddMemberBody": {
40203387 "type": "object",
40213388 "required": [
@@ -4025,10 +3392,10 @@
40253392 "role": {
40263393 "oneOf": [
40273394 {
4028 "$ref": "#/components/schemas/OrgMemberRole"
3395 "type": "null"
40293396 },
40303397 {
4031 "type": "null"
3398 "$ref": "#/components/schemas/OrgMemberRole"
40323399 }
40333400 ]
40343401 },
@@ -4044,58 +3411,6 @@
40443411 }
40453412 }
40463413 },
4047 "AppTokenRequest": {
4048 "type": "object",
4049 "required": [
4050 "app_id"
4051 ],
4052 "properties": {
4053 "app_id": {
4054 "type": "string"
4055 },
4056 "ttl_seconds": {
4057 "type": [
4058 "integer",
4059 "null"
4060 ],
4061 "format": "int32",
4062 "minimum": 0
4063 }
4064 }
4065 },
4066 "AppTokenResponse": {
4067 "type": "object",
4068 "required": [
4069 "app_id",
4070 "audience",
4071 "key_id",
4072 "token",
4073 "issued_at",
4074 "expires_at"
4075 ],
4076 "properties": {
4077 "app_id": {
4078 "type": "string"
4079 },
4080 "audience": {
4081 "type": "string"
4082 },
4083 "expires_at": {
4084 "type": "string",
4085 "format": "date-time"
4086 },
4087 "issued_at": {
4088 "type": "string",
4089 "format": "date-time"
4090 },
4091 "key_id": {
4092 "type": "string"
4093 },
4094 "token": {
4095 "type": "string"
4096 }
4097 }
4098 },
40993414 "AssignRoleBody": {
41003415 "type": "object",
41013416 "required": [
@@ -4170,21 +3485,6 @@
41703485 }
41713486 }
41723487 },
4173 "ComponentCheck": {
4174 "type": "object",
4175 "required": [
4176 "component",
4177 "ready"
4178 ],
4179 "properties": {
4180 "component": {
4181 "type": "string"
4182 },
4183 "ready": {
4184 "type": "boolean"
4185 }
4186 }
4187 },
41883488 "CreateInviteBody": {
41893489 "type": "object",
41903490 "properties": {
@@ -4272,10 +3572,10 @@
42723572 "join_policy": {
42733573 "oneOf": [
42743574 {
4275 "$ref": "#/components/schemas/OrganizationJoinPolicy"
3575 "type": "null"
42763576 },
42773577 {
4278 "type": "null"
3578 "$ref": "#/components/schemas/OrganizationJoinPolicy"
42793579 }
42803580 ]
42813581 },
@@ -4321,178 +3621,44 @@
43213621 "default_role": {
43223622 "type": "boolean"
43233623 },
4324 "description": {
4325 "type": [
4326 "string",
4327 "null"
4328 ]
4329 },
4330 "key": {
4331 "type": "string"
4332 },
4333 "name": {
4334 "type": "string"
4335 },
4336 "permissions": {
4337 "type": "array",
4338 "items": {
4339 "$ref": "#/components/schemas/PermissionGrant"
4340 }
4341 },
4342 "rank": {
4343 "type": "integer",
4344 "format": "int32"
4345 },
4346 "self_assignable": {
4347 "type": "boolean"
4348 }
4349 }
4350 },
4351 "DeleteOrgQuery": {
4352 "type": "object",
4353 "properties": {
4354 "force": {
4355 "type": "boolean"
4356 },
4357 "reason": {
4358 "type": [
4359 "string",
4360 "null"
4361 ]
4362 }
4363 }
4364 },
4365 "Envelope_AccessTokenCreateResponse": {
4366 "type": "object",
4367 "required": [
4368 "code",
4369 "message"
4370 ],
4371 "properties": {
4372 "code": {
4373 "type": "integer",
4374 "format": "int32"
4375 },
4376 "data": {
4377 "type": "object",
4378 "required": [
4379 "id",
4380 "name",
4381 "token",
4382 "scope",
4383 "created_at"
4384 ],
4385 "properties": {
4386 "created_at": {
4387 "type": "string",
4388 "format": "date-time"
4389 },
4390 "expires_at": {
4391 "type": [
4392 "string",
4393 "null"
4394 ],
4395 "format": "date-time"
4396 },
4397 "id": {
4398 "type": "string",
4399 "format": "uuid"
4400 },
4401 "name": {
4402 "type": "string"
4403 },
4404 "scope": {
4405 "type": "array",
4406 "items": {
4407 "type": "string"
4408 }
4409 },
4410 "token": {
4411 "type": "string"
4412 }
4413 }
4414 },
4415 "error": {
4416 "type": [
4417 "string",
4418 "null"
4419 ]
4420 },
4421 "error_code": {
3624 "description": {
44223625 "type": [
44233626 "string",
44243627 "null"
44253628 ]
44263629 },
4427 "message": {
3630 "key": {
44283631 "type": "string"
44293632 },
4430 "request_id": {
3633 "name": {
44313634 "type": "string"
3635 },
3636 "permissions": {
3637 "type": "array",
3638 "items": {
3639 "$ref": "#/components/schemas/PermissionGrant"
3640 }
3641 },
3642 "rank": {
3643 "type": "integer",
3644 "format": "int32"
3645 },
3646 "self_assignable": {
3647 "type": "boolean"
44323648 }
44333649 }
44343650 },
4435 "Envelope_AppTokenResponse": {
3651 "DeleteOrgQuery": {
44363652 "type": "object",
4437 "required": [
4438 "code",
4439 "message"
4440 ],
44413653 "properties": {
4442 "code": {
4443 "type": "integer",
4444 "format": "int32"
3654 "force": {
3655 "type": "boolean"
44453656 },
4446 "data": {
4447 "type": "object",
4448 "required": [
4449 "app_id",
4450 "audience",
4451 "key_id",
4452 "token",
4453 "issued_at",
4454 "expires_at"
4455 ],
4456 "properties": {
4457 "app_id": {
4458 "type": "string"
4459 },
4460 "audience": {
4461 "type": "string"
4462 },
4463 "expires_at": {
4464 "type": "string",
4465 "format": "date-time"
4466 },
4467 "issued_at": {
4468 "type": "string",
4469 "format": "date-time"
4470 },
4471 "key_id": {
4472 "type": "string"
4473 },
4474 "token": {
4475 "type": "string"
4476 }
4477 }
4478 },
4479 "error": {
4480 "type": [
4481 "string",
4482 "null"
4483 ]
4484 },
4485 "error_code": {
3657 "reason": {
44863658 "type": [
44873659 "string",
44883660 "null"
44893661 ]
4490 },
4491 "message": {
4492 "type": "string"
4493 },
4494 "request_id": {
4495 "type": "string"
44963662 }
44973663 }
44983664 },
@@ -4602,52 +3768,6 @@
46023768 }
46033769 }
46043770 },
4605 "Envelope_HealthResponse": {
4606 "type": "object",
4607 "required": [
4608 "code",
4609 "message"
4610 ],
4611 "properties": {
4612 "code": {
4613 "type": "integer",
4614 "format": "int32"
4615 },
4616 "data": {
4617 "type": "object",
4618 "required": [
4619 "status",
4620 "version"
4621 ],
4622 "properties": {
4623 "status": {
4624 "type": "string"
4625 },
4626 "version": {
4627 "type": "string"
4628 }
4629 }
4630 },
4631 "error": {
4632 "type": [
4633 "string",
4634 "null"
4635 ]
4636 },
4637 "error_code": {
4638 "type": [
4639 "string",
4640 "null"
4641 ]
4642 },
4643 "message": {
4644 "type": "string"
4645 },
4646 "request_id": {
4647 "type": "string"
4648 }
4649 }
4650 },
46513771 "Envelope_LoginCodeResponse": {
46523772 "type": "object",
46533773 "required": [
@@ -5130,10 +4250,10 @@
51304250 "my_role": {
51314251 "oneOf": [
51324252 {
5133 "$ref": "#/components/schemas/OrgMemberRole"
4253 "type": "null"
51344254 },
51354255 {
5136 "type": "null"
4256 "$ref": "#/components/schemas/OrgMemberRole"
51374257 }
51384258 ]
51394259 },
@@ -5334,55 +4454,6 @@
53344454 }
53354455 }
53364456 },
5337 "Envelope_ReadinessResponse": {
5338 "type": "object",
5339 "required": [
5340 "code",
5341 "message"
5342 ],
5343 "properties": {
5344 "code": {
5345 "type": "integer",
5346 "format": "int32"
5347 },
5348 "data": {
5349 "type": "object",
5350 "required": [
5351 "status",
5352 "checks"
5353 ],
5354 "properties": {
5355 "checks": {
5356 "type": "array",
5357 "items": {
5358 "$ref": "#/components/schemas/ComponentCheck"
5359 }
5360 },
5361 "status": {
5362 "type": "string"
5363 }
5364 }
5365 },
5366 "error": {
5367 "type": [
5368 "string",
5369 "null"
5370 ]
5371 },
5372 "error_code": {
5373 "type": [
5374 "string",
5375 "null"
5376 ]
5377 },
5378 "message": {
5379 "type": "string"
5380 },
5381 "request_id": {
5382 "type": "string"
5383 }
5384 }
5385 },
53864457 "Envelope_RedeemInviteResponse": {
53874458 "type": "object",
53884459 "required": [
@@ -5608,98 +4679,7 @@
56084679 }
56094680 }
56104681 },
5611 "Envelope_SshKeySummary": {
5612 "type": "object",
5613 "required": [
5614 "code",
5615 "message"
5616 ],
5617 "properties": {
5618 "code": {
5619 "type": "integer",
5620 "format": "int32"
5621 },
5622 "data": {
5623 "type": "object",
5624 "required": [
5625 "id",
5626 "name",
5627 "algorithm",
5628 "fingerprint",
5629 "public_key",
5630 "created_at"
5631 ],
5632 "properties": {
5633 "algorithm": {
5634 "$ref": "#/components/schemas/SshKeyAlgorithm"
5635 },
5636 "created_at": {
5637 "type": "string",
5638 "format": "date-time"
5639 },
5640 "description": {
5641 "type": [
5642 "string",
5643 "null"
5644 ]
5645 },
5646 "expires_at": {
5647 "type": [
5648 "string",
5649 "null"
5650 ],
5651 "format": "date-time"
5652 },
5653 "fingerprint": {
5654 "type": "string"
5655 },
5656 "id": {
5657 "type": "string",
5658 "format": "uuid"
5659 },
5660 "last_used_at": {
5661 "type": [
5662 "string",
5663 "null"
5664 ],
5665 "format": "date-time"
5666 },
5667 "name": {
5668 "type": "string"
5669 },
5670 "public_key": {
5671 "type": "string"
5672 },
5673 "revoked_at": {
5674 "type": [
5675 "string",
5676 "null"
5677 ],
5678 "format": "date-time"
5679 }
5680 }
5681 },
5682 "error": {
5683 "type": [
5684 "string",
5685 "null"
5686 ]
5687 },
5688 "error_code": {
5689 "type": [
5690 "string",
5691 "null"
5692 ]
5693 },
5694 "message": {
5695 "type": "string"
5696 },
5697 "request_id": {
5698 "type": "string"
5699 }
5700 }
5701 },
5702 "Envelope_TokenResponse": {
4682 "Envelope_SshKeySummary": {
57034683 "type": "object",
57044684 "required": [
57054685 "code",
@@ -5713,29 +4693,60 @@
57134693 "data": {
57144694 "type": "object",
57154695 "required": [
5716 "token",
5717 "token_type",
5718 "kid",
5719 "issued_at",
5720 "expires_at"
4696 "id",
4697 "name",
4698 "algorithm",
4699 "fingerprint",
4700 "public_key",
4701 "created_at"
57214702 ],
57224703 "properties": {
4704 "algorithm": {
4705 "$ref": "#/components/schemas/SshKeyAlgorithm"
4706 },
4707 "created_at": {
4708 "type": "string",
4709 "format": "date-time"
4710 },
4711 "description": {
4712 "type": [
4713 "string",
4714 "null"
4715 ]
4716 },
57234717 "expires_at": {
5724 "type": "string",
4718 "type": [
4719 "string",
4720 "null"
4721 ],
57254722 "format": "date-time"
57264723 },
5727 "issued_at": {
4724 "fingerprint": {
4725 "type": "string"
4726 },
4727 "id": {
57284728 "type": "string",
4729 "format": "uuid"
4730 },
4731 "last_used_at": {
4732 "type": [
4733 "string",
4734 "null"
4735 ],
57294736 "format": "date-time"
57304737 },
5731 "kid": {
4738 "name": {
57324739 "type": "string"
57334740 },
5734 "token": {
4741 "public_key": {
57354742 "type": "string"
57364743 },
5737 "token_type": {
5738 "type": "string"
4744 "revoked_at": {
4745 "type": [
4746 "string",
4747 "null"
4748 ],
4749 "format": "date-time"
57394750 }
57404751 }
57414752 },
@@ -5875,101 +4886,6 @@
58754886 }
58764887 }
58774888 },
5878 "Envelope_Vec_AccessTokenSummary": {
5879 "type": "object",
5880 "required": [
5881 "code",
5882 "message"
5883 ],
5884 "properties": {
5885 "code": {
5886 "type": "integer",
5887 "format": "int32"
5888 },
5889 "data": {
5890 "type": "array",
5891 "items": {
5892 "type": "object",
5893 "required": [
5894 "id",
5895 "name",
5896 "scope",
5897 "created_at"
5898 ],
5899 "properties": {
5900 "created_at": {
5901 "type": "string",
5902 "format": "date-time"
5903 },
5904 "description": {
5905 "type": [
5906 "string",
5907 "null"
5908 ]
5909 },
5910 "expires_at": {
5911 "type": [
5912 "string",
5913 "null"
5914 ],
5915 "format": "date-time"
5916 },
5917 "fingerprint": {
5918 "type": [
5919 "string",
5920 "null"
5921 ]
5922 },
5923 "id": {
5924 "type": "string",
5925 "format": "uuid"
5926 },
5927 "last_used_at": {
5928 "type": [
5929 "string",
5930 "null"
5931 ],
5932 "format": "date-time"
5933 },
5934 "name": {
5935 "type": "string"
5936 },
5937 "revoked_at": {
5938 "type": [
5939 "string",
5940 "null"
5941 ],
5942 "format": "date-time"
5943 },
5944 "scope": {
5945 "type": "array",
5946 "items": {
5947 "type": "string"
5948 }
5949 }
5950 }
5951 }
5952 },
5953 "error": {
5954 "type": [
5955 "string",
5956 "null"
5957 ]
5958 },
5959 "error_code": {
5960 "type": [
5961 "string",
5962 "null"
5963 ]
5964 },
5965 "message": {
5966 "type": "string"
5967 },
5968 "request_id": {
5969 "type": "string"
5970 }
5971 }
5972 },
59734889 "Envelope_Vec_InviteSummary": {
59744890 "type": "object",
59754891 "required": [
@@ -6361,10 +5277,10 @@
63615277 "my_role": {
63625278 "oneOf": [
63635279 {
6364 "$ref": "#/components/schemas/OrgMemberRole"
5280 "type": "null"
63655281 },
63665282 {
6367 "type": "null"
5283 "$ref": "#/components/schemas/OrgMemberRole"
63685284 }
63695285 ]
63705286 },
@@ -6721,21 +5637,6 @@
67215637 }
67225638 }
67235639 },
6724 "HealthResponse": {
6725 "type": "object",
6726 "required": [
6727 "status",
6728 "version"
6729 ],
6730 "properties": {
6731 "status": {
6732 "type": "string"
6733 },
6734 "version": {
6735 "type": "string"
6736 }
6737 }
6738 },
67395640 "InviteSummary": {
67405641 "type": "object",
67415642 "required": [
@@ -7342,10 +6243,10 @@
73426243 "my_role": {
73436244 "oneOf": [
73446245 {
7345 "$ref": "#/components/schemas/OrgMemberRole"
6246 "type": "null"
73466247 },
73476248 {
7348 "type": "null"
6249 "$ref": "#/components/schemas/OrgMemberRole"
73496250 }
73506251 ]
73516252 },
@@ -7511,24 +6412,6 @@
75116412 }
75126413 }
75136414 },
7514 "ReadinessResponse": {
7515 "type": "object",
7516 "required": [
7517 "status",
7518 "checks"
7519 ],
7520 "properties": {
7521 "checks": {
7522 "type": "array",
7523 "items": {
7524 "$ref": "#/components/schemas/ComponentCheck"
7525 }
7526 },
7527 "status": {
7528 "type": "string"
7529 }
7530 }
7531 },
75326415 "ReasonBody": {
75336416 "type": "object",
75346417 "properties": {
@@ -7820,88 +6703,6 @@
78206703 }
78216704 }
78226705 },
7823 "TokenRequest": {
7824 "type": "object",
7825 "required": [
7826 "grant_type"
7827 ],
7828 "properties": {
7829 "client_id": {
7830 "type": [
7831 "string",
7832 "null"
7833 ]
7834 },
7835 "client_secret": {
7836 "type": [
7837 "string",
7838 "null"
7839 ]
7840 },
7841 "code": {
7842 "type": [
7843 "string",
7844 "null"
7845 ]
7846 },
7847 "code_verifier": {
7848 "type": [
7849 "string",
7850 "null"
7851 ]
7852 },
7853 "grant_type": {
7854 "type": "string"
7855 },
7856 "redirect_uri": {
7857 "type": [
7858 "string",
7859 "null"
7860 ]
7861 },
7862 "refresh_token": {
7863 "type": [
7864 "string",
7865 "null"
7866 ]
7867 },
7868 "scope": {
7869 "type": [
7870 "string",
7871 "null"
7872 ]
7873 }
7874 }
7875 },
7876 "TokenResponse": {
7877 "type": "object",
7878 "required": [
7879 "token",
7880 "token_type",
7881 "kid",
7882 "issued_at",
7883 "expires_at"
7884 ],
7885 "properties": {
7886 "expires_at": {
7887 "type": "string",
7888 "format": "date-time"
7889 },
7890 "issued_at": {
7891 "type": "string",
7892 "format": "date-time"
7893 },
7894 "kid": {
7895 "type": "string"
7896 },
7897 "token": {
7898 "type": "string"
7899 },
7900 "token_type": {
7901 "type": "string"
7902 }
7903 }
7904 },
79056706 "TransferBody": {
79066707 "type": "object",
79076708 "required": [
@@ -7986,10 +6787,10 @@
79866787 "message_notifications": {
79876788 "oneOf": [
79886789 {
7989 "$ref": "#/components/schemas/MessageNotifications"
6790 "type": "null"
79906791 },
79916792 {
7992 "type": "null"
6793 "$ref": "#/components/schemas/MessageNotifications"
79936794 }
79946795 ]
79956796 },
@@ -8067,10 +6868,10 @@
80676868 "join_policy": {
80686869 "oneOf": [
80696870 {
8070 "$ref": "#/components/schemas/OrganizationJoinPolicy"
6871 "type": "null"
80716872 },
80726873 {
8073 "type": "null"
6874 "$ref": "#/components/schemas/OrganizationJoinPolicy"
80746875 }
80756876 ]
80766877 },
@@ -8258,4 +7059,4 @@
82587059 }
82597060 }
82607061 }
8261}
7062}
No newline at end of file
Modified lib/git/src/handler/tag_protection/create_policy.rs View lib/git/src/handler/tag_protection/create_policy.rs
@@ -10,6 +10,7 @@
1010#[utoipa::path(
1111 post,
1212 path = "/repos/{space_name}/{repo_name}/tag-protection",
13 operation_id = "create_tag_policy",
1314 tag = "tag_protection",
1415 description = "为标签名模式创建保护策略;模式命中且创建者不在白名单内的 tag 推送将被拒绝。",
1516 params(RepoPath),
Modified lib/git/src/handler/tag_protection/delete_policy.rs View lib/git/src/handler/tag_protection/delete_policy.rs
@@ -10,6 +10,7 @@
1010#[utoipa::path(
1111 delete,
1212 path = "/repos/{space_name}/{repo_name}/tag-protection/{policy_id}",
13 operation_id = "delete_tag_policy",
1314 tag = "tag_protection",
1415 description = "删除标签保护策略,并清理该策略下的用户白名单。",
1516 params(PolicyPath),
Modified lib/git/src/handler/tag_protection/get_policy.rs View lib/git/src/handler/tag_protection/get_policy.rs
@@ -11,6 +11,7 @@
1111#[utoipa::path(
1212 get,
1313 path = "/repos/{space_name}/{repo_name}/tag-protection/{policy_id}",
14 operation_id = "get_tag_policy",
1415 tag = "tag_protection",
1516 description = "读取单条标签保护策略。",
1617 params(PolicyPath),
Modified lib/git/src/handler/tag_protection/list_policies.rs View lib/git/src/handler/tag_protection/list_policies.rs
@@ -10,6 +10,7 @@
1010#[utoipa::path(
1111 get,
1212 path = "/repos/{space_name}/{repo_name}/tag-protection",
13 operation_id = "list_tag_policies",
1314 tag = "tag_protection",
1415 description = "列出标签保护策略。",
1516 params(RepoPath),
Modified lib/git/src/handler/tag_protection/update_policy.rs View lib/git/src/handler/tag_protection/update_policy.rs
@@ -11,6 +11,7 @@
1111#[utoipa::path(
1212 patch,
1313 path = "/repos/{space_name}/{repo_name}/tag-protection/{policy_id}",
14 operation_id = "update_tag_policy",
1415 tag = "tag_protection",
1516 description = "更新标签保护策略的模式。",
1617 params(PolicyPath),
Modified lib/git/src/openapi.rs View lib/git/src/openapi.rs
@@ -172,11 +172,8 @@
172172 crate::service::refs::UpdateRefResultDto,
173173 crate::service::remotes::RemoteDto,
174174 crate::service::remotes::FetchResultDto,
175 crate::service::remotes::RemoteDto,
176175 crate::service::remotes::ImportResultDto,
177 crate::service::remotes::RemoteDto,
178176 crate::service::remotes::PushResultDto,
179 crate::service::remotes::RemoteDto,
180177 crate::service::remotes::RemoteAuthDto,
181178 crate::service::repos::CreateRepoCmd,
182179 crate::service::repos::RepoDto,
Modified view/auth/src/app/routing/router.tsx View view/auth/src/app/routing/router.tsx
@@ -1,5 +1,5 @@
1import type { ComponentChild, ComponentChildren } from 'preact'
2import { useEffect, useState } from 'preact/hooks'
1import type { ReactNode } from 'react'
2import { useEffect, useState } from 'react'
33import { captureCallbackUrl } from '@/entities/auth-callback'
44import type { LoginComplete } from '@/entities/session'
55import { mfaChallenge, useSession } from '@/entities/session'
@@ -20,7 +20,7 @@
2020import { navigate, usePath } from '@/shared/lib'
2121import { Alert, Button, Heading } from '@/shared/ui'
2222
23function LoginEntry({ children }: { children: ComponentChildren }) {
23function LoginEntry({ children }: { children: ReactNode }) {
2424 const [unauthenticated, setUnauthenticated] = useState(false)
2525 const { user, loading, task } = useSession({ onUnauthenticated: () => setUnauthenticated(true) })
2626 useEffect(() => {
@@ -31,7 +31,11 @@
3131 <>
3232 <Heading title={t('account.checking')} />
3333 <Alert notice={task.notice} />
34 {!loading && task.notice && <Button type="button" onClick={() => location.reload()}>{t('common.retry')}</Button>}
34 {!loading && task.notice && (
35 <Button type="button" onClick={() => location.reload()}>
36 {t('common.retry')}
37 </Button>
38 )}
3539 </>
3640 )
3741}
@@ -53,14 +57,22 @@
5357 const path = usePath()
5458 captureCallbackUrl()
5559 const onComplete = useLoginComplete()
56 let page: ComponentChild
60 let page: ReactNode
5761 switch (path) {
5862 case routes.home:
5963 case routes.login:
60 page = <LoginEntry><LoginPage onComplete={onComplete} /></LoginEntry>
64 page = (
65 <LoginEntry>
66 <LoginPage onComplete={onComplete} />
67 </LoginEntry>
68 )
6169 break
6270 case routes.loginCode:
63 page = <LoginEntry><LoginCodePage onComplete={onComplete} /></LoginEntry>
71 page = (
72 <LoginEntry>
73 <LoginCodePage onComplete={onComplete} />
74 </LoginEntry>
75 )
6476 break
6577 case routes.loginVerify:
6678 page = <LoginVerifyPage onComplete={onComplete} />
@@ -86,7 +98,7 @@
8698 case routes.account:
8799 page = <AccountPage />
88100 break
89 default:
101 default:
90102 page = <NotFoundPage />
91103 }
92104 return <div key={path + location.search}>{page}</div>
Modified view/auth/src/app/app.tsx View view/auth/src/app/app.tsx
@@ -1,4 +1,4 @@
1import { useEffect } from 'preact/hooks'
1import { useEffect } from 'react'
22import { registrationDraft } from '@/entities/registration'
33import { mfaChallenge } from '@/entities/session'
44import { routes } from '@/shared/config'
Modified view/auth/src/app/main.tsx View view/auth/src/app/main.tsx
@@ -1,4 +1,4 @@
1import { render } from 'preact'
1import { createRoot } from 'react-dom/client'
22import './styles/index.css'
33import { captureCallbackUrl } from '@/entities/auth-callback'
44import { App } from './app'
@@ -6,4 +6,4 @@
66captureCallbackUrl()
77
88const root = document.getElementById('app')
9if (root) render(<App />, root)
9if (root) createRoot(root).render(<App />)
Modified view/auth/src/entities/auth-callback/model/callback-url.ts View view/auth/src/entities/auth-callback/model/callback-url.ts
@@ -6,7 +6,9 @@
66
77export function clearCallbackUrl() {
88 callbackUrl.set('')
9 try { sessionStorage.removeItem(STORAGE_KEY) } catch {}
9 try {
10 sessionStorage.removeItem(STORAGE_KEY)
11 } catch {}
1012}
1113
1214function isLoopback(hostname: string): boolean {
@@ -31,7 +33,9 @@
3133 const value = sanitize(params.get('callback_url'))
3234 if (value) {
3335 callbackUrl.set(value)
34 try { sessionStorage.setItem(STORAGE_KEY, value) } catch {}
36 try {
37 sessionStorage.setItem(STORAGE_KEY, value)
38 } catch {}
3539 } else clearCallbackUrl()
3640 return value
3741 }
@@ -49,4 +53,4 @@
4953 } catch {}
5054 }
5155 return value
52}
No newline at end of file
56}
Modified view/auth/src/entities/session/model/use-session.ts View view/auth/src/entities/session/model/use-session.ts
@@ -1,5 +1,5 @@
11import type { MeResponse } from '@/shared/api'
2import { useEffect, useState } from 'preact/hooks'
2import { useEffect, useState } from 'react'
33import { AuthError } from '@/shared/api'
44import { t } from '@/shared/i18n'
55import { useTask } from '@/shared/lib'
Modified view/auth/src/features/login-by-code/model/use-login-by-code.ts View view/auth/src/features/login-by-code/model/use-login-by-code.ts
@@ -1,4 +1,4 @@
1import { useState } from 'preact/hooks'
1import { useState } from 'react'
22import type { LoginComplete } from '@/entities/session'
33import { markCode, useCodeTimer } from '@/entities/verification-code'
44import { t } from '@/shared/i18n'
Modified view/auth/src/features/login-by-code/ui/login-code-form.tsx View view/auth/src/features/login-by-code/ui/login-code-form.tsx
@@ -24,7 +24,7 @@
2424 error={task.errors.email}
2525 disabled={task.busy}
2626 />
27 <div class="mt-6">
27 <div className="mt-6">
2828 <Button busy={task.busy} disabled={retry > 0}>
2929 {t('loginCode.send')}
3030 </Button>
@@ -33,12 +33,12 @@
3333 )
3434 return (
3535 <>
36 <div class="mb-6 flex flex-wrap items-center justify-center gap-2">
37 <span class="max-w-full font-latin text-sm font-medium wrap-anywhere">{sentEmail}</span>
36 <div className="mb-6 flex flex-wrap items-center justify-center gap-2">
37 <span className="max-w-full font-latin text-sm font-medium wrap-anywhere">{sentEmail}</span>
3838 <button
3939 type="button"
4040 disabled={task.busy}
41 class="min-h-11 px-1 text-xs text-accent-text hover:underline"
41 className="min-h-11 px-1 text-xs text-accent-text hover:underline"
4242 onClick={changeEmail}
4343 >
4444 {t('loginCode.changeEmail')}
@@ -54,11 +54,11 @@
5454 >
5555 <Otp value={code} onInput={setCode} error={task.errors.code} />
5656 {timer.sent && timer.expiry === 0 && (
57 <p role="status" class="mt-2 text-xs text-danger">
57 <p role="status" className="mt-2 text-xs text-danger">
5858 {t('loginCode.expired')}
5959 </p>
6060 )}
61 <div class="mt-6">
61 <div className="mt-6">
6262 <Button busy={task.busy} disabled={retry > 0 || !timer.expiry}>
6363 {t('loginCode.submit')}
6464 </Button>
Modified view/auth/src/features/login-by-password/model/use-login-by-password.ts View view/auth/src/features/login-by-password/model/use-login-by-password.ts
@@ -1,4 +1,4 @@
1import { useState } from 'preact/hooks'
1import { useState } from 'react'
22import type { LoginComplete } from '@/entities/session'
33import { t } from '@/shared/i18n'
44import type { Errors } from '@/shared/lib'
Modified view/auth/src/features/login-by-password/ui/login-form.tsx View view/auth/src/features/login-by-password/ui/login-form.tsx
@@ -14,7 +14,7 @@
1414 submit()
1515 }}
1616 >
17 <div class="flex flex-col gap-4">
17 <div className="flex flex-col gap-4">
1818 <Field
1919 name="identity"
2020 label={t('login.identity.label')}
@@ -38,7 +38,7 @@
3838 disabled={task.busy}
3939 />
4040 </div>
41 <div class="flex items-center justify-between py-2">
41 <div className="flex items-center justify-between py-2">
4242 <Link to={routes.loginCode}>{t('login.codeLink')}</Link>
4343 <Link to={routes.forgotPassword}>{t('login.forgotLink')}</Link>
4444 </div>
Modified view/auth/src/features/register-account/model/use-register-account.ts View view/auth/src/features/register-account/model/use-register-account.ts
@@ -1,4 +1,4 @@
1import { useState } from 'preact/hooks'
1import { useState } from 'react'
22import { registrationDraft, useRegistrationDraft } from '@/entities/registration'
33import { markCode, useCodeTimer } from '@/entities/verification-code'
44import { t } from '@/shared/i18n'
Modified view/auth/src/features/register-account/model/use-register-verify.ts View view/auth/src/features/register-account/model/use-register-verify.ts
@@ -1,4 +1,4 @@
1import { useState } from 'preact/hooks'
1import { useState } from 'react'
22import { useRegistrationDraft } from '@/entities/registration'
33import { markCode, useCodeTimer } from '@/entities/verification-code'
44import { t } from '@/shared/i18n'
Modified view/auth/src/features/register-account/ui/register-form.tsx View view/auth/src/features/register-account/ui/register-form.tsx
@@ -30,7 +30,7 @@
3030 submit()
3131 }}
3232 >
33 <div class="flex flex-col gap-4">
33 <div className="flex flex-col gap-4">
3434 <Field
3535 name="email"
3636 label={t('field.email.label')}
Modified view/auth/src/features/register-account/ui/register-verify-form.tsx View view/auth/src/features/register-account/ui/register-verify-form.tsx
@@ -16,18 +16,18 @@
1616 >
1717 <Otp value={code} onInput={setCode} error={task.errors.code} />
1818 {timer.sent && !timer.expiry && (
19 <p role="status" class="mt-2 text-xs text-danger">
19 <p role="status" className="mt-2 text-xs text-danger">
2020 {t('registerVerify.expired')}
2121 </p>
2222 )}
23 <div class="mt-6">
23 <div className="mt-6">
2424 <Button busy={task.busy} disabled={retry > 0 || !timer.expiry}>
2525 {t('registerVerify.submit')}
2626 </Button>
2727 </div>
2828 </form>
2929 {(task.errors.username || task.errors.email || task.errors.birth || task.errors.password) && (
30 <div class="mt-3 text-center">
30 <div className="mt-3 text-center">
3131 <Link to={routes.register}>{t('registerVerify.backToForm')}</Link>
3232 </div>
3333 )}
Modified view/auth/src/features/request-password-reset/model/use-request-password-reset.ts View view/auth/src/features/request-password-reset/model/use-request-password-reset.ts
@@ -1,4 +1,4 @@
1import { useState } from 'preact/hooks'
1import { useState } from 'react'
22import { passwordResetState } from '@/entities/password-reset'
33import { markCode, useCodeTimer } from '@/entities/verification-code'
44import { emailError, normalizeEmail, useCountdown, useTask } from '@/shared/lib'
Modified view/auth/src/features/request-password-reset/ui/forgot-password-form.tsx View view/auth/src/features/request-password-reset/ui/forgot-password-form.tsx
@@ -23,7 +23,7 @@
2323 error={task.errors.email}
2424 disabled={task.busy}
2525 />
26 <div class="mt-6">
26 <div className="mt-6">
2727 <Button busy={task.busy} disabled={retry > 0}>
2828 {t('forgot.submit')}
2929 </Button>
Modified view/auth/src/features/reset-password/model/use-reset-password.ts View view/auth/src/features/reset-password/model/use-reset-password.ts
@@ -1,4 +1,4 @@
1import { useState } from 'preact/hooks'
1import { useState } from 'react'
22import { passwordResetState } from '@/entities/password-reset'
33import { t } from '@/shared/i18n'
44import type { Errors } from '@/shared/lib'
Modified view/auth/src/features/reset-password/ui/reset-password-form.tsx View view/auth/src/features/reset-password/ui/reset-password-form.tsx
@@ -12,7 +12,7 @@
1212 submit()
1313 }}
1414 >
15 <div class="flex flex-col gap-4">
15 <div className="flex flex-col gap-4">
1616 <Field
1717 name="password"
1818 label={t('resetPassword.newLabel')}
@@ -39,7 +39,7 @@
3939 disabled={task.busy}
4040 />
4141 </div>
42 <div class="mt-6">
42 <div className="mt-6">
4343 <Button busy={task.busy} disabled={retry > 0}>
4444 {t('resetPassword.submit')}
4545 </Button>
Modified view/auth/src/features/theme-toggle/model/use-theme.ts View view/auth/src/features/theme-toggle/model/use-theme.ts
@@ -1,4 +1,4 @@
1import { useEffect, useState } from 'preact/hooks'
1import { useEffect, useState } from 'react'
22
33export function useTheme() {
44 const [theme, setTheme] = useState(() => {
Modified view/auth/src/features/theme-toggle/ui/theme-switch.tsx View view/auth/src/features/theme-toggle/ui/theme-switch.tsx
@@ -1,4 +1,4 @@
1import { Moon, Sun } from 'lucide-preact'
1import { Moon, Sun } from 'lucide-react'
22import { t } from '@/shared/i18n'
33import type { ThemeModel } from '../model/use-theme'
44
@@ -7,7 +7,7 @@
77 return (
88 <button
99 type="button"
10 class="flex size-11 items-center justify-center rounded-lg text-icon hover:bg-elevated hover:text-ink"
10 className="flex size-11 items-center justify-center rounded-lg text-icon hover:bg-elevated hover:text-ink"
1111 aria-label={t(dark ? 'theme.toLight' : 'theme.toDark')}
1212 title={theme === 'system' ? t('theme.system') : t(dark ? 'theme.currentDark' : 'theme.currentLight')}
1313 onClick={toggle}
Modified view/auth/src/features/verify-mfa/model/use-verify-mfa.ts View view/auth/src/features/verify-mfa/model/use-verify-mfa.ts
@@ -1,4 +1,4 @@
1import { useState } from 'preact/hooks'
1import { useState } from 'react'
22import type { LoginComplete } from '@/entities/session'
33import { t } from '@/shared/i18n'
44import { useCountdown, useTask } from '@/shared/lib'
Modified view/auth/src/features/verify-mfa/ui/mfa-form.tsx View view/auth/src/features/verify-mfa/ui/mfa-form.tsx
@@ -35,19 +35,19 @@
3535 helper={t('mfa.totpHelper')}
3636 />
3737 )}
38 <div class="mt-6">
38 <div className="mt-6">
3939 <Button busy={task.busy} disabled={retry > 0}>
4040 {t('mfa.submit')}
4141 </Button>
4242 </div>
4343 <RetryHint seconds={retry} />
4444 </form>
45 <div class="mt-4 text-center">
45 <div className="mt-4 text-center">
4646 <button
4747 type="button"
4848 disabled={task.busy}
4949 onClick={toggleRecovery}
50 class="min-h-11 text-xs text-accent-text hover:underline"
50 className="min-h-11 text-xs text-accent-text hover:underline"
5151 >
5252 {recovery ? t('mfa.useTotp') : t('mfa.useRecovery')}
5353 </button>
Modified view/auth/src/pages/account/ui/account-page.tsx View view/auth/src/pages/account/ui/account-page.tsx
@@ -1,5 +1,5 @@
1import { CircleCheck } from 'lucide-preact'
2import { useEffect } from 'preact/hooks'
1import { CircleCheck } from 'lucide-react'
2import { useEffect } from 'react'
33import { callbackUrl, clearCallbackUrl } from '@/entities/auth-callback'
44import { useSession } from '@/entities/session'
55import { routes } from '@/shared/config'
@@ -21,13 +21,18 @@
2121 }, CALLBACK_REDIRECT_DELAY_MS)
2222 return () => clearTimeout(timer)
2323 }, [user])
24 if (loading || !user) return (
25 <>
26 <Heading title={t('account.checking')} />
27 <Alert notice={task.notice} />
28 {!loading && task.notice && <Button type="button" onClick={() => location.reload()}>{t('common.retry')}</Button>}
29 </>
30 )
24 if (loading || !user)
25 return (
26 <>
27 <Heading title={t('account.checking')} />
28 <Alert notice={task.notice} />
29 {!loading && task.notice && (
30 <Button type="button" onClick={() => location.reload()}>
31 {t('common.retry')}
32 </Button>
33 )}
34 </>
35 )
3136 return (
3237 <>
3338 <Heading title={t('account.titleDone')} description={t('account.descriptionDone')} icon={CircleCheck} success />
Modified view/auth/src/pages/forgot-password/ui/forgot-password-page.tsx View view/auth/src/pages/forgot-password/ui/forgot-password-page.tsx
@@ -1,4 +1,4 @@
1import { KeyRound } from 'lucide-preact'
1import { KeyRound } from 'lucide-react'
22import { ForgotPasswordForm, useRequestPasswordReset } from '@/features/request-password-reset'
33import { t } from '@/shared/i18n'
44import { Alert, BackLink, Heading } from '@/shared/ui'
Modified view/auth/src/pages/login-verify/ui/login-verify-page.tsx View view/auth/src/pages/login-verify/ui/login-verify-page.tsx
@@ -1,4 +1,4 @@
1import { ShieldCheck } from 'lucide-preact'
1import { ShieldCheck } from 'lucide-react'
22import type { LoginComplete } from '@/entities/session'
33import { useMfaChallenge } from '@/entities/session'
44import { useVerifyMfa, VerifyMfaForm } from '@/features/verify-mfa'
Modified view/auth/src/pages/register-verify/ui/register-verify-page.tsx View view/auth/src/pages/register-verify/ui/register-verify-page.tsx
@@ -1,4 +1,4 @@
1import { ShieldCheck } from 'lucide-preact'
1import { ShieldCheck } from 'lucide-react'
22import { RegisterVerifyForm, useRegisterVerify } from '@/features/register-account'
33import { routes } from '@/shared/config'
44import { t } from '@/shared/i18n'
@@ -25,8 +25,8 @@
2525 description={t('registerVerify.description')}
2626 />
2727 <Alert notice={model.task.notice} />
28 <div class="mb-8 flex flex-wrap items-center justify-center gap-2">
29 <span class="max-w-full font-latin text-sm font-medium wrap-anywhere">{model.draft.email}</span>
28 <div className="mb-8 flex flex-wrap items-center justify-center gap-2">
29 <span className="max-w-full font-latin text-sm font-medium wrap-anywhere">{model.draft.email}</span>
3030 <Link to={routes.register}>{t('registerVerify.changeEmail')}</Link>
3131 </div>
3232 <RegisterVerifyForm model={model} />
Modified view/auth/src/pages/reset-complete/ui/reset-complete-page.tsx View view/auth/src/pages/reset-complete/ui/reset-complete-page.tsx
@@ -1,4 +1,4 @@
1import { Check, ShieldCheck } from 'lucide-preact'
1import { Check, ShieldCheck } from 'lucide-react'
22import { usePasswordReset } from '@/entities/password-reset'
33import { routes } from '@/shared/config'
44import { t } from '@/shared/i18n'
@@ -24,7 +24,7 @@
2424 <Button type="button" onClick={() => navigate(routes.login)}>
2525 {t('common.backToLogin')}
2626 </Button>
27 <p class="mt-4 text-center text-xs leading-[1.65] text-muted">{t('resetComplete.hint')}</p>
27 <p className="mt-4 text-center text-xs leading-[1.65] text-muted">{t('resetComplete.hint')}</p>
2828 </>
2929 )
3030}
Modified view/auth/src/pages/reset-password/ui/reset-password-page.tsx View view/auth/src/pages/reset-password/ui/reset-password-page.tsx
@@ -1,5 +1,5 @@
1import { KeyRound, LockKeyhole } from 'lucide-preact'
2import { useState } from 'preact/hooks'
1import { KeyRound, LockKeyhole } from 'lucide-react'
2import { useState } from 'react'
33import { ResetPasswordForm, useResetPassword } from '@/features/reset-password'
44import { routes } from '@/shared/config'
55import { t } from '@/shared/i18n'
@@ -35,7 +35,7 @@
3535 <ResetPasswordForm model={model} />
3636 <BackLink />
3737 {model.task.code === 'invalid_reset_token' && (
38 <div class="text-center">
38 <div className="text-center">
3939 <Link to={routes.forgotPassword}>{t('resetPassword.invalidAction')}</Link>
4040 </div>
4141 )}
Modified view/auth/src/pages/reset-sent/ui/reset-sent-page.tsx View view/auth/src/pages/reset-sent/ui/reset-sent-page.tsx
@@ -1,4 +1,4 @@
1import { MailCheck } from 'lucide-preact'
1import { MailCheck } from 'lucide-react'
22import { ResetSentActions, useResendResetEmail } from '@/features/request-password-reset'
33import { routes } from '@/shared/config'
44import { t } from '@/shared/i18n'
@@ -22,16 +22,16 @@
2222 <>
2323 <Heading title={t('resetSent.title')} description={t('resetSent.description')} icon={MailCheck} />
2424 <Alert notice={model.task.notice} />
25 <p class="mb-6 text-center font-latin text-sm font-medium wrap-anywhere">{model.email}</p>
26 <p class="text-center text-xs leading-[1.65] text-muted">{t('resetSent.hint')}</p>
27 <div class="mt-6">
25 <p className="mb-6 text-center font-latin text-sm font-medium wrap-anywhere">{model.email}</p>
26 <p className="text-center text-xs leading-[1.65] text-muted">{t('resetSent.hint')}</p>
27 <div className="mt-6">
2828 <Button type="button" onClick={() => navigate(routes.login)}>
2929 {t('common.backToLogin')}
3030 </Button>
3131 </div>
3232 <ResetSentActions model={model} />
33 <div class="mt-6 flex items-center justify-center gap-1 text-xs">
34 <span class="text-muted">{t('resetSent.wrongEmail')}</span>
33 <div className="mt-6 flex items-center justify-center gap-1 text-xs">
34 <span className="text-muted">{t('resetSent.wrongEmail')}</span>
3535 <Link to={routes.forgotPassword}>{t('resetSent.changeEmail')}</Link>
3636 </div>
3737 </>
Modified view/auth/src/shared/api/auth/auth.schemas.ts View view/auth/src/shared/api/auth/auth.schemas.ts
@@ -2,70 +2,16 @@
22 * Generated by orval 🍺
33 * Do not edit manually.
44 * auth
5 * 认证与账号服务:工作量证明、口令与验证码登录、二次验证、注册、口令重置、访问令牌与 SSH 公钥。
5 * 认证与账号服务:工作量证明、口令与验证码登录、二次验证、注册、口令重置与 SSH 公钥。
66 * OpenAPI spec version: 1.0.0
77 */
8export interface AccessTokenCreateRequest {
9 /** @nullable */
10 description?: string | null;
11 /** @nullable */
12 expires_in_secs?: number | null;
13 name: string;
14 scope: string[];
15}
16
17export interface AccessTokenCreateResponse {
18 created_at: string;
19 /** @nullable */
20 expires_at?: string | null;
21 id: string;
22 name: string;
23 scope: string[];
24 token: string;
25}
26
27export interface AccessTokenSummary {
28 created_at: string;
29 /** @nullable */
30 description?: string | null;
31 /** @nullable */
32 expires_at?: string | null;
33 /** @nullable */
34 fingerprint?: string | null;
35 id: string;
36 /** @nullable */
37 last_used_at?: string | null;
38 name: string;
39 /** @nullable */
40 revoked_at?: string | null;
41 scope: string[];
42}
43
448export type OrgMemberRole = "member" | "admin" | "owner";
459export interface AddMemberBody {
46 role?: OrgMemberRole | null;
10 role?: null | OrgMemberRole;
4711 role_keys?: string[];
4812 user_id: number;
4913}
5014
51export interface AppTokenRequest {
52 app_id: string;
53 /**
54 * @minimum 0
55 * @nullable
56 */
57 ttl_seconds?: number | null;
58}
59
60export interface AppTokenResponse {
61 app_id: string;
62 audience: string;
63 expires_at: string;
64 issued_at: string;
65 key_id: string;
66 token: string;
67}
68
6915export interface AssignRoleBody {
7016 member_id: string;
7117 role_key: string;
@@ -92,11 +38,6 @@
9238 user_id?: number | null;
9339}
9440
95export interface ComponentCheck {
96 component: string;
97 ready: boolean;
98}
99
10041export interface CreateInviteBody {
10142 max_uses?: number;
10243 /** @nullable */
@@ -150,7 +91,7 @@
15091 avatar_url?: string | null;
15192 /** @nullable */
15293 description?: string | null;
153 join_policy?: OrganizationJoinPolicy | null;
94 join_policy?: null | OrganizationJoinPolicy;
15495 /** @nullable */
15596 member_must_enable_2fa?: boolean | null;
15697 name: string;
@@ -186,47 +127,6 @@
186127 reason?: string | null;
187128}
188129
189export type EnvelopeAccessTokenCreateResponseData = {
190 created_at: string;
191 /** @nullable */
192 expires_at?: string | null;
193 id: string;
194 name: string;
195 scope: string[];
196 token: string;
197};
198
199export interface EnvelopeAccessTokenCreateResponse {
200 code: number;
201 data?: EnvelopeAccessTokenCreateResponseData;
202 /** @nullable */
203 error?: string | null;
204 /** @nullable */
205 error_code?: string | null;
206 message: string;
207 request_id?: string;
208}
209
210export type EnvelopeAppTokenResponseData = {
211 app_id: string;
212 audience: string;
213 expires_at: string;
214 issued_at: string;
215 key_id: string;
216 token: string;
217};
218
219export interface EnvelopeAppTokenResponse {
220 code: number;
221 data?: EnvelopeAppTokenResponseData;
222 /** @nullable */
223 error?: string | null;
224 /** @nullable */
225 error_code?: string | null;
226 message: string;
227 request_id?: string;
228}
229
230130export type EnvelopeChallengeResponseData = {
231131 context: string;
232132 /** @minimum 0 */
@@ -264,22 +164,6 @@
264164 request_id?: string;
265165}
266166
267export type EnvelopeHealthResponseData = {
268 status: string;
269 version: string;
270};
271
272export interface EnvelopeHealthResponse {
273 code: number;
274 data?: EnvelopeHealthResponseData;
275 /** @nullable */
276 error?: string | null;
277 /** @nullable */
278 error_code?: string | null;
279 message: string;
280 request_id?: string;
281}
282
283167export type EnvelopeLoginCodeResponseData = {
284168 /** @minimum 0 */
285169 expires_in_secs: number;
@@ -424,7 +308,7 @@
424308 join_policy: OrganizationJoinPolicy;
425309 member_count: number;
426310 member_must_enable_2fa: boolean;
427 my_role?: OrgMemberRole | null;
311 my_role?: null | OrgMemberRole;
428312 name: string;
429313 owner_user_id: number;
430314 require_email_verified: boolean;
@@ -497,22 +381,6 @@
497381 request_id?: string;
498382}
499383
500export type EnvelopeReadinessResponseData = {
501 checks: ComponentCheck[];
502 status: string;
503};
504
505export interface EnvelopeReadinessResponse {
506 code: number;
507 data?: EnvelopeReadinessResponseData;
508 /** @nullable */
509 error?: string | null;
510 /** @nullable */
511 error_code?: string | null;
512 message: string;
513 request_id?: string;
514}
515
516384export type EnvelopeRedeemInviteResponseData = {
517385 joined: boolean;
518386 member_id: string;
@@ -611,25 +479,6 @@
611479 request_id?: string;
612480}
613481
614export type EnvelopeTokenResponseData = {
615 expires_at: string;
616 issued_at: string;
617 kid: string;
618 token: string;
619 token_type: string;
620};
621
622export interface EnvelopeTokenResponse {
623 code: number;
624 data?: EnvelopeTokenResponseData;
625 /** @nullable */
626 error?: string | null;
627 /** @nullable */
628 error_code?: string | null;
629 message: string;
630 request_id?: string;
631}
632
633482export type EnvelopeUserLoginResponseData = {
634483 /** @nullable */
635484 challenge?: string | null;
@@ -669,34 +518,6 @@
669518 request_id?: string;
670519}
671520
672export type EnvelopeVecAccessTokenSummaryDataItem = {
673 created_at: string;
674 /** @nullable */
675 description?: string | null;
676 /** @nullable */
677 expires_at?: string | null;
678 /** @nullable */
679 fingerprint?: string | null;
680 id: string;
681 /** @nullable */
682 last_used_at?: string | null;
683 name: string;
684 /** @nullable */
685 revoked_at?: string | null;
686 scope: string[];
687};
688
689export interface EnvelopeVecAccessTokenSummary {
690 code: number;
691 data?: EnvelopeVecAccessTokenSummaryDataItem[];
692 /** @nullable */
693 error?: string | null;
694 /** @nullable */
695 error_code?: string | null;
696 message: string;
697 request_id?: string;
698}
699
700521export type EnvelopeVecInviteSummaryDataItem = {
701522 created_at: string;
702523 created_by: number;
@@ -797,7 +618,7 @@
797618 join_policy: OrganizationJoinPolicy;
798619 member_count: number;
799620 member_must_enable_2fa: boolean;
800 my_role?: OrgMemberRole | null;
621 my_role?: null | OrgMemberRole;
801622 name: string;
802623 owner_user_id: number;
803624 require_email_verified: boolean;
@@ -905,11 +726,6 @@
905726 error_code?: string | null;
906727 message: string;
907728 request_id?: string;
908}
909
910export interface HealthResponse {
911 status: string;
912 version: string;
913729}
914730
915731export interface PowProofRequest {
@@ -1071,7 +887,7 @@
1071887 join_policy: OrganizationJoinPolicy;
1072888 member_count: number;
1073889 member_must_enable_2fa: boolean;
1074 my_role?: OrgMemberRole | null;
890 my_role?: null | OrgMemberRole;
1075891 name: string;
1076892 owner_user_id: number;
1077893 require_email_verified: boolean;
@@ -1111,11 +927,6 @@
1111927 reason: string;
1112928}
1113929
1114export interface ReadinessResponse {
1115 checks: ComponentCheck[];
1116 status: string;
1117}
1118
1119930export interface ReasonBody {
1120931 /** @nullable */
1121932 reason?: string | null;
@@ -1201,32 +1012,6 @@
12011012 revoked_at?: string | null;
12021013}
12031014
1204export interface TokenRequest {
1205 /** @nullable */
1206 client_id?: string | null;
1207 /** @nullable */
1208 client_secret?: string | null;
1209 /** @nullable */
1210 code?: string | null;
1211 /** @nullable */
1212 code_verifier?: string | null;
1213 grant_type: string;
1214 /** @nullable */
1215 redirect_uri?: string | null;
1216 /** @nullable */
1217 refresh_token?: string | null;
1218 /** @nullable */
1219 scope?: string | null;
1220}
1221
1222export interface TokenResponse {
1223 expires_at: string;
1224 issued_at: string;
1225 kid: string;
1226 token: string;
1227 token_type: string;
1228}
1229
12301015export interface TransferBody {
12311016 target_user_id: number;
12321017}
@@ -1261,7 +1046,7 @@
12611046 expected_version?: number;
12621047 /** @nullable */
12631048 hide_muted_channels?: boolean | null;
1264 message_notifications?: MessageNotifications | null;
1049 message_notifications?: null | MessageNotifications;
12651050 /** @nullable */
12661051 mobile_push?: boolean | null;
12671052 /** @nullable */
@@ -1286,7 +1071,7 @@
12861071 /** @nullable */
12871072 description?: string | null;
12881073 expected_version?: number;
1289 join_policy?: OrganizationJoinPolicy | null;
1074 join_policy?: null | OrganizationJoinPolicy;
12901075 /** @nullable */
12911076 member_must_enable_2fa?: boolean | null;
12921077 /** @nullable */
@@ -1346,19 +1131,6 @@
13461131 user_id: number;
13471132 username: string;
13481133}
1349
1350export type AuthAccessTokenListParams = {
1351 /**
1352 * 页码,从 1 开始;默认 1
1353 * @minimum 0
1354 */
1355 page?: number;
1356 /**
1357 * 每页条数,默认 20,最多 100
1358 * @minimum 0
1359 */
1360 page_size?: number;
1361};
13621134
13631135export type AuthSshKeyListParams = {
13641136 /**
Modified view/auth/src/shared/api/auth/auth.ts View view/auth/src/shared/api/auth/auth.ts
@@ -2,15 +2,12 @@
22 * Generated by orval 🍺
33 * Do not edit manually.
44 * auth
5 * 认证与账号服务:工作量证明、口令与验证码登录、二次验证、注册、口令重置、访问令牌与 SSH 公钥。
5 * 认证与账号服务:工作量证明、口令与验证码登录、二次验证、注册、口令重置与 SSH 公钥。
66 * OpenAPI spec version: 1.0.0
77 */
88import type {
9 AccessTokenCreateRequest,
109 AddMemberBody,
11 AppTokenRequest,
1210 AssignRoleBody,
13 AuthAccessTokenListParams,
1411 AuthOrganizationBanListParams,
1512 AuthOrganizationDeleteParams,
1613 AuthOrganizationInviteListParams,
@@ -22,12 +19,9 @@
2219 CreateInviteBody,
2320 CreateOrganizationBody,
2421 CreateRoleBody,
25 EnvelopeAccessTokenCreateResponse,
26 EnvelopeAppTokenResponse,
2722 EnvelopeBool,
2823 EnvelopeChallengeResponse,
2924 EnvelopeCreateInviteResponse,
30 EnvelopeHealthResponse,
3125 EnvelopeLoginCodeResponse,
3226 EnvelopeLogoutResponse,
3327 EnvelopeMeResponse,
@@ -37,15 +31,12 @@
3731 EnvelopePasswordResetConfirmResponse,
3832 EnvelopePasswordResetResponse,
3933 EnvelopePermissionCheck,
40 EnvelopeReadinessResponse,
4134 EnvelopeRedeemInviteResponse,
4235 EnvelopeRegisterCodeResponse,
4336 EnvelopeRoleSummary,
4437 EnvelopeSshKeySummary,
45 EnvelopeTokenResponse,
4638 EnvelopeUserLoginResponse,
4739 EnvelopeUserRegisterResponse,
48 EnvelopeVecAccessTokenSummary,
4940 EnvelopeVecInviteSummary,
5041 EnvelopeVecMemberSummary,
5142 EnvelopeVecOrganizationSummary,
@@ -63,7 +54,6 @@
6354 SetMemberRoleBody,
6455 SetRolePermissionsBody,
6556 SshKeyAddRequest,
66 TokenRequest,
6757 TransferBody,
6858 UpdateBanBody,
6959 UpdateMemberBody,
@@ -75,296 +65,6 @@
7565
7666import { authClient } from "../../lib/http/http";
7767
78export type authAccessTokenListResponse200 = {
79 data: EnvelopeVecAccessTokenSummary;
80 status: 200;
81};
82
83export type authAccessTokenListResponse401 = {
84 data: EnvelopeNoData;
85 status: 401;
86};
87
88export type authAccessTokenListResponse500 = {
89 data: EnvelopeNoData;
90 status: 500;
91};
92
93export type authAccessTokenListResponseSuccess =
94 authAccessTokenListResponse200 & {
95 headers: Headers;
96 };
97export type authAccessTokenListResponseError = (
98 authAccessTokenListResponse401 | authAccessTokenListResponse500
99) & {
100 headers: Headers;
101};
102
103export type authAccessTokenListResponse =
104 authAccessTokenListResponseSuccess | authAccessTokenListResponseError;
105
106export const getAuthAccessTokenListUrl = (
107 params?: AuthAccessTokenListParams,
108) => {
109 const normalizedParams = new URLSearchParams();
110
111 Object.entries(params || {}).forEach(([key, value]) => {
112 if (value !== undefined) {
113 normalizedParams.append(key, value === null ? "null" : String(value));
114 }
115 });
116
117 const stringifiedParams = normalizedParams.toString();
118
119 return stringifiedParams.length > 0
120 ? `/api/v1/oauth/access-tokens?${stringifiedParams}`
121 : `/api/v1/oauth/access-tokens`;
122};
123
124/**
125 * 按创建时间倒序返回当前账号的令牌摘要,只给指纹与状态,不回吐令牌原文。分页参数由 `page` / `page_size` 控制。查询串不是合法分页参数或路径/方法不匹配时返回 400 / 404 由 actix-web 直接给出。
126 * @summary 列示访问令牌
127 */
128export const authAccessTokenList = async (
129 params?: AuthAccessTokenListParams,
130 options?: Parameters<typeof authClient>[1],
131): Promise<authAccessTokenListResponse> => {
132 return authClient<authAccessTokenListResponse>(
133 getAuthAccessTokenListUrl(params),
134 {
135 ...options,
136 method: "GET",
137 },
138 );
139};
140
141export type authAccessTokenCreateResponse200 = {
142 data: EnvelopeAccessTokenCreateResponse;
143 status: 200;
144};
145
146export type authAccessTokenCreateResponse400 = {
147 data: EnvelopeNoData;
148 status: 400;
149};
150
151export type authAccessTokenCreateResponse401 = {
152 data: EnvelopeNoData;
153 status: 401;
154};
155
156export type authAccessTokenCreateResponse429 = {
157 data: EnvelopeNoData;
158 status: 429;
159};
160
161export type authAccessTokenCreateResponse500 = {
162 data: EnvelopeNoData;
163 status: 500;
164};
165
166export type authAccessTokenCreateResponseSuccess =
167 authAccessTokenCreateResponse200 & {
168 headers: Headers;
169 };
170export type authAccessTokenCreateResponseError = (
171 | authAccessTokenCreateResponse400
172 | authAccessTokenCreateResponse401
173 | authAccessTokenCreateResponse429
174 | authAccessTokenCreateResponse500
175) & {
176 headers: Headers;
177};
178
179export type authAccessTokenCreateResponse =
180 authAccessTokenCreateResponseSuccess | authAccessTokenCreateResponseError;
181
182export const getAuthAccessTokenCreateUrl = () => {
183 return `/api/v1/oauth/access-tokens`;
184};
185
186/**
187 * 名称 1~64 字符、备注最长 255 字符,`scope` 必须全部命中白名单(最多 32 项),`expires_in_secs` 取值 60 ~ 31536000(不填表示长期有效)。同一账号未吊销的令牌上限 50 个。签发成功记 `AccessKeyAdded` 审计事件。
188 * @summary 签发访问令牌
189 */
190export const authAccessTokenCreate = async (
191 accessTokenCreateRequest: AccessTokenCreateRequest,
192 options?: Parameters<typeof authClient>[1],
193): Promise<authAccessTokenCreateResponse> => {
194 const getHeaders = (
195 h?: NonNullable<RequestInit["headers"]>,
196 ): Record<string, string | readonly string[]> => {
197 if (!h) return {};
198 if (h instanceof Headers) return Object.fromEntries(h.entries());
199 if (Symbol.iterator in h) {
200 return Object.fromEntries(
201 Array.from(
202 h as Iterable<Iterable<string>>,
203 (entry) => Array.from(entry) as [string, string],
204 ),
205 );
206 }
207 const headers: Record<string, string | readonly string[]> = {};
208 for (const [name, value] of Object.entries<
209 string | readonly string[] | undefined
210 >(h)) {
211 if (value !== undefined) headers[name] = value;
212 }
213 return headers;
214 };
215 return authClient<authAccessTokenCreateResponse>(
216 getAuthAccessTokenCreateUrl(),
217 {
218 ...options,
219 method: "POST",
220 headers: {
221 "Content-Type": "application/json",
222 ...getHeaders(options?.headers),
223 },
224 body: JSON.stringify(accessTokenCreateRequest),
225 },
226 );
227};
228
229export type authAccessTokenRevokeResponse200 = {
230 data: EnvelopeNoData;
231 status: 200;
232};
233
234export type authAccessTokenRevokeResponse401 = {
235 data: EnvelopeNoData;
236 status: 401;
237};
238
239export type authAccessTokenRevokeResponse500 = {
240 data: EnvelopeNoData;
241 status: 500;
242};
243
244export type authAccessTokenRevokeResponseSuccess =
245 authAccessTokenRevokeResponse200 & {
246 headers: Headers;
247 };
248export type authAccessTokenRevokeResponseError = (
249 authAccessTokenRevokeResponse401 | authAccessTokenRevokeResponse500
250) & {
251 headers: Headers;
252};
253
254export type authAccessTokenRevokeResponse =
255 authAccessTokenRevokeResponseSuccess | authAccessTokenRevokeResponseError;
256
257export const getAuthAccessTokenRevokeUrl = (tokenId: string) => {
258 return `/api/v1/oauth/access-tokens/${tokenId}`;
259};
260
261/**
262 * 把令牌标记为已吊销,记录保留不硬删;重复调用幂等。令牌不存在或不属于当前账号返回 `access_token_not_found`(按 `errors.rs` 的归类为 401)。吊销成功记 `AccessKeyRemoved` 审计事件。
263 * @summary 吊销访问令牌
264 */
265export const authAccessTokenRevoke = async (
266 tokenId: string,
267 options?: Parameters<typeof authClient>[1],
268): Promise<authAccessTokenRevokeResponse> => {
269 return authClient<authAccessTokenRevokeResponse>(
270 getAuthAccessTokenRevokeUrl(tokenId),
271 {
272 ...options,
273 method: "DELETE",
274 },
275 );
276};
277
278export type authAppTokenIssueResponse200 = {
279 data: EnvelopeAppTokenResponse;
280 status: 200;
281};
282
283export type authAppTokenIssueResponse400 = {
284 data: EnvelopeNoData;
285 status: 400;
286};
287
288export type authAppTokenIssueResponse401 = {
289 data: EnvelopeNoData;
290 status: 401;
291};
292
293export type authAppTokenIssueResponse404 = {
294 data: EnvelopeNoData;
295 status: 404;
296};
297
298export type authAppTokenIssueResponse500 = {
299 data: EnvelopeNoData;
300 status: 500;
301};
302
303export type authAppTokenIssueResponse503 = {
304 data: EnvelopeNoData;
305 status: 503;
306};
307
308export type authAppTokenIssueResponseSuccess = authAppTokenIssueResponse200 & {
309 headers: Headers;
310};
311export type authAppTokenIssueResponseError = (
312 | authAppTokenIssueResponse400
313 | authAppTokenIssueResponse401
314 | authAppTokenIssueResponse404
315 | authAppTokenIssueResponse500
316 | authAppTokenIssueResponse503
317) & {
318 headers: Headers;
319};
320
321export type authAppTokenIssueResponse =
322 authAppTokenIssueResponseSuccess | authAppTokenIssueResponseError;
323
324export const getAuthAppTokenIssueUrl = () => {
325 return `/api/v1/oauth/app-tokens`;
326};
327
328/**
329 * 认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 为目标 app 的 audience,`client_id` 为 app 标识。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。会话未绑定账号返回 `unauthenticated`(401);app 未在 `apps` 清单里返回 `app_not_found`(404);私钥未配置返回 `unavailable`(503)。
330 * @summary 签发内部应用令牌
331 */
332export const authAppTokenIssue = async (
333 appTokenRequest: AppTokenRequest,
334 options?: Parameters<typeof authClient>[1],
335): Promise<authAppTokenIssueResponse> => {
336 const getHeaders = (
337 h?: NonNullable<RequestInit["headers"]>,
338 ): Record<string, string | readonly string[]> => {
339 if (!h) return {};
340 if (h instanceof Headers) return Object.fromEntries(h.entries());
341 if (Symbol.iterator in h) {
342 return Object.fromEntries(
343 Array.from(
344 h as Iterable<Iterable<string>>,
345 (entry) => Array.from(entry) as [string, string],
346 ),
347 );
348 }
349 const headers: Record<string, string | readonly string[]> = {};
350 for (const [name, value] of Object.entries<
351 string | readonly string[] | undefined
352 >(h)) {
353 if (value !== undefined) headers[name] = value;
354 }
355 return headers;
356 };
357 return authClient<authAppTokenIssueResponse>(getAuthAppTokenIssueUrl(), {
358 ...options,
359 method: "POST",
360 headers: {
361 "Content-Type": "application/json",
362 ...getHeaders(options?.headers),
363 },
364 body: JSON.stringify(appTokenRequest),
365 });
366};
367
36868export type authChallengeResponse200 = {
36969 data: EnvelopeChallengeResponse;
37070 status: 200;
@@ -386,7 +86,7 @@
38686 authChallengeResponseSuccess | authChallengeResponseError;
38787
38888export const getAuthChallengeUrl = () => {
389 return `/api/v1/oauth/challenge`;
89 return `/api/v1/auth/challenge`;
39090};
39191
39292/**
@@ -449,11 +149,11 @@
449149 authLoginResponseSuccess | authLoginResponseError;
450150
451151export const getAuthLoginUrl = () => {
452 return `/api/v1/oauth/login`;
152 return `/api/v1/auth/login`;
453153};
454154
455155/**
456 * 用口令登录。`identity` 可以是用户名、邮箱(大小写不敏感)或用户编号,`password_hash` 是客户端先算好的口令摘要,工作量证明的载荷必须是 `identity`。账号启用了多因子时返回 `mfa_required: true` 与 `challenge`,此时会话尚未绑定,需再调 `/api/v1/oauth/login/verify`。
156 * 用口令登录。`identity` 可以是用户名、邮箱(大小写不敏感)或用户编号,`password_hash` 是客户端先算好的口令摘要,工作量证明的载荷必须是 `identity`。账号启用了多因子时返回 `mfa_required: true` 与 `challenge`,此时会话尚未绑定,需再调 `/api/v1/auth/login/verify`。
457157 * @summary 口令登录
458158 */
459159export const authLogin = async (
@@ -539,7 +239,7 @@
539239 authLoginCodeResponseSuccess | authLoginCodeResponseError;
540240
541241export const getAuthLoginCodeUrl = () => {
542 return `/api/v1/oauth/login/code`;
242 return `/api/v1/auth/login/code`;
543243};
544244
545245/**
@@ -630,7 +330,7 @@
630330 authLoginCodeVerifyResponseSuccess | authLoginCodeVerifyResponseError;
631331
632332export const getAuthLoginCodeVerifyUrl = () => {
633 return `/api/v1/oauth/login/code/verify`;
333 return `/api/v1/auth/login/code/verify`;
634334};
635335
636336/**
@@ -720,11 +420,11 @@
720420 authLoginVerifyResponseSuccess | authLoginVerifyResponseError;
721421
722422export const getAuthLoginVerifyUrl = () => {
723 return `/api/v1/oauth/login/verify`;
423 return `/api/v1/auth/login/verify`;
724424};
725425
726426/**
727 * 用 6 位 TOTP 动态码或一枚未使用的恢复码完成二次验证并绑定会话。`challenge` 必须与 `/api/v1/oauth/login` 或 `/api/v1/oauth/login/code/verify` 返回的一致,挑战有效期 300 秒,同一动态码时间步不能重复使用,恢复码用掉即作废。
427 * 用 6 位 TOTP 动态码或一枚未使用的恢复码完成二次验证并绑定会话。`challenge` 必须与 `/api/v1/auth/login` 或 `/api/v1/auth/login/code/verify` 返回的一致,挑战有效期 300 秒,同一动态码时间步不能重复使用,恢复码用掉即作废。
728428 * @summary 二次验证换会话
729429 */
730430export const authLoginVerify = async (
@@ -773,11 +473,6 @@
773473 status: 401;
774474};
775475
776export type authMeResponse403 = {
777 data: EnvelopeNoData;
778 status: 403;
779};
780
781476export type authMeResponse500 = {
782477 data: EnvelopeNoData;
783478 status: 500;
@@ -786,20 +481,18 @@
786481export type authMeResponseSuccess = authMeResponse200 & {
787482 headers: Headers;
788483};
789export type authMeResponseError = (
790 authMeResponse401 | authMeResponse403 | authMeResponse500
791) & {
484export type authMeResponseError = (authMeResponse401 | authMeResponse500) & {
792485 headers: Headers;
793486};
794487
795488export type authMeResponse = authMeResponseSuccess | authMeResponseError;
796489
797490export const getAuthMeUrl = () => {
798 return `/api/v1/oauth/me`;
491 return `/api/v1/auth/me`;
799492};
800493
801494/**
802 * 通过会话 Cookie 或 Bearer 访问令牌返回用户自述信息,含头像地址。Bearer 令牌需具备 profile、email、avatar 权限,并校验账号与会话状态。结果在服务端缓存 300 秒,每第 5 次访问回源刷新;登出与改密会主动失效该缓存。
495 * 通过会话 Cookie 返回用户自述信息。结果在服务端缓存 300 秒,每第 5 次访问回源刷新;登出与改密会主动失效该缓存。
803496 * @summary 当前登录用户
804497 */
805498export const authMe = async (
@@ -848,7 +541,7 @@
848541 | authPasswordResetRequestResponseError;
849542
850543export const getAuthPasswordResetRequestUrl = () => {
851 return `/api/v1/oauth/password/reset`;
544 return `/api/v1/auth/password/reset`;
852545};
853546
854547/**
@@ -929,7 +622,7 @@
929622 authPasswordResetResponseSuccess | authPasswordResetResponseError;
930623
931624export const getAuthPasswordResetUrl = () => {
932 return `/api/v1/oauth/password/reset/confirm`;
625 return `/api/v1/auth/password/reset/confirm`;
933626};
934627
935628/**
@@ -1019,7 +712,7 @@
1019712 authRegisterResponseSuccess | authRegisterResponseError;
1020713
1021714export const getAuthRegisterUrl = () => {
1022 return `/api/v1/oauth/register`;
715 return `/api/v1/auth/register`;
1023716};
1024717
1025718/**
@@ -1103,7 +796,7 @@
1103796 authRegisterCodeResponseSuccess | authRegisterCodeResponseError;
1104797
1105798export const getAuthRegisterCodeUrl = () => {
1106 return `/api/v1/oauth/register/code`;
799 return `/api/v1/auth/register/code`;
1107800};
1108801
1109802/**
@@ -1167,11 +860,11 @@
1167860 authLogoutResponseSuccess | authLogoutResponseError;
1168861
1169862export const getAuthLogoutUrl = () => {
1170 return `/api/v1/oauth/session/logout`;
863 return `/api/v1/auth/session/logout`;
1171864};
1172865
1173866/**
1174 * 销毁当前会话,并清掉该用户的 `/api/v1/oauth/me` 缓存。匿名调用同样成功,此时 `user_id` 为 `null`。
867 * 销毁当前会话,并清掉该用户的 `/api/v1/auth/me` 缓存。匿名调用同样成功,此时 `user_id` 为 `null`。
1175868 * @summary 登出
1176869 */
1177870export const authLogout = async (
@@ -1183,84 +876,6 @@
1183876 });
1184877};
1185878
1186export type authTokenIssueResponse200 = {
1187 data: EnvelopeTokenResponse;
1188 status: 200;
1189};
1190
1191export type authTokenIssueResponse400 = {
1192 data: EnvelopeNoData;
1193 status: 400;
1194};
1195
1196export type authTokenIssueResponse401 = {
1197 data: EnvelopeNoData;
1198 status: 401;
1199};
1200
1201export type authTokenIssueResponse404 = {
1202 data: EnvelopeNoData;
1203 status: 404;
1204};
1205
1206export type authTokenIssueResponseSuccess = authTokenIssueResponse200 & {
1207 headers: Headers;
1208};
1209export type authTokenIssueResponseError = (
1210 | authTokenIssueResponse400
1211 | authTokenIssueResponse401
1212 | authTokenIssueResponse404
1213) & {
1214 headers: Headers;
1215};
1216
1217export type authTokenIssueResponse =
1218 authTokenIssueResponseSuccess | authTokenIssueResponseError;
1219
1220export const getAuthTokenIssueUrl = () => {
1221 return `/api/v1/oauth/session/token`;
1222};
1223
1224/**
1225 * 认证中心用 Ed25519 私钥签发 JWT(`alg=EdDSA`),`aud` 固定为 `gitdata.ai`,`azp`(`app_id`)可选。服务通过内部 gRPC authority 验签。`ttl_seconds` 取值 1 ~ 3600,不填或填 0 用默认 900。
1226 * @summary 用会话换取平台访问令牌
1227 */
1228export const authTokenIssue = async (
1229 tokenRequest: TokenRequest,
1230 options?: Parameters<typeof authClient>[1],
1231): Promise<authTokenIssueResponse> => {
1232 const getHeaders = (
1233 h?: NonNullable<RequestInit["headers"]>,
1234 ): Record<string, string | readonly string[]> => {
1235 if (!h) return {};
1236 if (h instanceof Headers) return Object.fromEntries(h.entries());
1237 if (Symbol.iterator in h) {
1238 return Object.fromEntries(
1239 Array.from(
1240 h as Iterable<Iterable<string>>,
1241 (entry) => Array.from(entry) as [string, string],
1242 ),
1243 );
1244 }
1245 const headers: Record<string, string | readonly string[]> = {};
1246 for (const [name, value] of Object.entries<
1247 string | readonly string[] | undefined
1248 >(h)) {
1249 if (value !== undefined) headers[name] = value;
1250 }
1251 return headers;
1252 };
1253 return authClient<authTokenIssueResponse>(getAuthTokenIssueUrl(), {
1254 ...options,
1255 method: "POST",
1256 headers: {
1257 "Content-Type": "application/json",
1258 ...getHeaders(options?.headers),
1259 },
1260 body: JSON.stringify(tokenRequest),
1261 });
1262};
1263
1264879export type authSshKeyListResponse200 = {
1265880 data: EnvelopeVecSshKeySummary;
1266881 status: 200;
@@ -1300,8 +915,8 @@
1300915 const stringifiedParams = normalizedParams.toString();
1301916
1302917 return stringifiedParams.length > 0
1303 ? `/api/v1/oauth/ssh-keys?${stringifiedParams}`
1304 : `/api/v1/oauth/ssh-keys`;
918 ? `/api/v1/auth/ssh-keys?${stringifiedParams}`
919 : `/api/v1/auth/ssh-keys`;
1305920};
1306921
1307922/**
@@ -1359,7 +974,7 @@
1359974 authSshKeyAddResponseSuccess | authSshKeyAddResponseError;
1360975
1361976export const getAuthSshKeyAddUrl = () => {
1362 return `/api/v1/oauth/ssh-keys`;
977 return `/api/v1/auth/ssh-keys`;
1363978};
1364979
1365980/**
@@ -1437,7 +1052,7 @@
14371052 authSshKeyDeleteResponseSuccess | authSshKeyDeleteResponseError;
14381053
14391054export const getAuthSshKeyDeleteUrl = (keyId: string) => {
1440 return `/api/v1/oauth/ssh-keys/${keyId}`;
1055 return `/api/v1/auth/ssh-keys/${keyId}`;
14411056};
14421057
14431058/**
@@ -3822,68 +3437,4 @@
38223437 body: JSON.stringify(transferBody),
38233438 },
38243439 );
3825};
3826
3827export type healthLiveResponse200 = {
3828 data: EnvelopeHealthResponse;
3829 status: 200;
3830};
3831
3832export type healthLiveResponseSuccess = healthLiveResponse200 & {
3833 headers: Headers;
3834};
3835export type healthLiveResponse = healthLiveResponseSuccess;
3836
3837export const getHealthLiveUrl = () => {
3838 return `/health`;
3839};
3840
3841/**
3842 * 只回答进程是否还在处理请求,不触碰任何外部依赖:数据库、Redis、RocketMQ 全都不可用时依然返回 200。用于区分「进程死了」和「依赖挂了」。
3843 * @summary 存活探针
3844 */
3845export const healthLive = async (
3846 options?: Parameters<typeof authClient>[1],
3847): Promise<healthLiveResponse> => {
3848 return authClient<healthLiveResponse>(getHealthLiveUrl(), {
3849 ...options,
3850 method: "GET",
3851 });
3852};
3853
3854export type healthReadyResponse200 = {
3855 data: EnvelopeReadinessResponse;
3856 status: 200;
3857};
3858
3859export type healthReadyResponse503 = {
3860 data: EnvelopeReadinessResponse;
3861 status: 503;
3862};
3863
3864export type healthReadyResponseSuccess = healthReadyResponse200 & {
3865 headers: Headers;
3866};
3867export type healthReadyResponseError = healthReadyResponse503 & {
3868 headers: Headers;
3869};
3870
3871export type healthReadyResponse =
3872 healthReadyResponseSuccess | healthReadyResponseError;
3873
3874export const getHealthReadyUrl = () => {
3875 return `/ready`;
3876};
3877
3878/**
3879 * 并发探测启动时必须连上的三个依赖——PostgreSQL、Redis、RocketMQ,全部可用返回 200,任一不可用返回 503 并在 `checks` 里标出是哪一项。单项探测上限 2 秒,失败细节只写日志,不进响应体。
3880 * @summary 就绪探针
3881 */
3882export const healthReady = async (
3883 options?: Parameters<typeof authClient>[1],
3884): Promise<healthReadyResponse> => {
3885 return authClient<healthReadyResponse>(getHealthReadyUrl(), {
3886 ...options,
3887 method: "GET",
3888 });
38893440};
Modified view/auth/src/shared/i18n/locale.ts View view/auth/src/shared/i18n/locale.ts
@@ -1,4 +1,4 @@
1import { useEffect, useState } from 'preact/hooks'
1import { useEffect, useState } from 'react'
22
33export const LOCALES = ['zh-CN', 'en'] as const
44
Modified view/auth/src/shared/i18n/messages.ts View view/auth/src/shared/i18n/messages.ts
@@ -206,7 +206,6 @@
206206 'account.descriptionDone': '你已安全登录 GitDataAI。',
207207 'account.unavailable': '暂时无法读取账户信息。',
208208
209
210209 'scope.user_name': '读取你的用户名',
211210 'scope.profile': '读取你的个人资料',
212211 'scope.email': '读取你的邮箱地址',
@@ -432,7 +431,6 @@
432431 'account.titleDone': 'Signed in',
433432 'account.descriptionDone': 'You are signed in to GitDataAI.',
434433 'account.unavailable': 'Your account details are unavailable right now.',
435
436434
437435 'scope.user_name': 'Read your username',
438436 'scope.profile': 'Read your profile',
Modified view/auth/src/shared/lib/http/http.ts View view/auth/src/shared/lib/http/http.ts
@@ -37,7 +37,15 @@
3737 path: string,
3838 options: RequestOptions = {},
3939): Promise<globalThis.Response> => {
40 const { baseUrl, timeoutMs, responseType: _responseType, auth: _auth, handleUnauthorized: _handleUnauthorized, manager: _manager, ...init } = options
40 const {
41 baseUrl,
42 timeoutMs,
43 responseType: _responseType,
44 auth: _auth,
45 handleUnauthorized: _handleUnauthorized,
46 manager: _manager,
47 ...init
48 } = options
4149 const signal = AbortSignal.timeout(timeoutMs ?? 20_000)
4250 return (globalThis.fetch ?? fetch)(buildUrl(path, baseUrl), {
4351 ...init,
@@ -55,7 +63,10 @@
5563 const contentType = response.headers.get('Content-Type')?.toLowerCase() ?? ''
5664 let data: unknown = null
5765 if (options.method?.toUpperCase() !== 'HEAD' && ![204, 205, 304].includes(response.status)) {
58 if (options.responseType === 'blob' || /^(application\/(octet-stream|zip|x-tar|gzip)|image\/|video\/|audio\/)/.test(contentType)) {
66 if (
67 options.responseType === 'blob' ||
68 /^(application\/(octet-stream|zip|x-tar|gzip)|image\/|video\/|audio\/)/.test(contentType)
69 ) {
5970 data = await response.blob()
6071 } else if (contentType.includes('json')) {
6172 data = await response.json()
@@ -66,13 +77,10 @@
6677 return { data: data as T, status: response.status, headers: response.headers }
6778}
6879
69export const authClient = <T>(path: string, options: RequestOptions = {}) =>
70 request<T>('auth', path, options)
80export const authClient = <T>(path: string, options: RequestOptions = {}) => request<T>('auth', path, options)
7181
72export const apiClient = <T>(path: string, options: RequestOptions = {}) =>
73 request<T>('api', path, options)
82export const apiClient = <T>(path: string, options: RequestOptions = {}) => request<T>('api', path, options)
7483
75export const gitClient = <T>(path: string, options: RequestOptions = {}) =>
76 request<T>('git', path, options)
84export const gitClient = <T>(path: string, options: RequestOptions = {}) => request<T>('git', path, options)
7785
7886export const configureRequests = (_options: unknown): void => {}
Modified view/auth/src/shared/lib/navigation.ts View view/auth/src/shared/lib/navigation.ts
@@ -1,4 +1,4 @@
1import { useEffect, useState } from 'preact/hooks'
1import { useEffect, useState } from 'react'
22
33export function navigationHref(path: string): string {
44 const target = new URL(path, location.href)
Modified view/auth/src/shared/lib/store.ts View view/auth/src/shared/lib/store.ts
@@ -1,4 +1,4 @@
1import { useEffect, useState } from 'preact/hooks'
1import { useEffect, useState } from 'react'
22
33export interface Store<T> {
44 get(): T
Modified view/auth/src/shared/lib/use-countdown.ts View view/auth/src/shared/lib/use-countdown.ts
@@ -1,4 +1,4 @@
1import { useEffect, useState } from 'preact/hooks'
1import { useEffect, useState } from 'react'
22
33export function useCountdown(deadline: number) {
44 const [now, setNow] = useState(Date.now())
Modified view/auth/src/shared/lib/use-task.ts View view/auth/src/shared/lib/use-task.ts
@@ -1,4 +1,4 @@
1import { useEffect, useRef, useState } from 'preact/hooks'
1import { useEffect, useRef, useState } from 'react'
22import { AuthError } from '@/shared/api'
33import { t } from '@/shared/i18n'
44
Modified view/auth/src/shared/ui/agreement.tsx View view/auth/src/shared/ui/agreement.tsx
@@ -1,4 +1,4 @@
1import { Check } from 'lucide-preact'
1import { Check } from 'lucide-react'
22import { t } from '@/shared/i18n'
33import { LegalLink } from './legal-link'
44
@@ -14,9 +14,12 @@
1414 disabled?: boolean
1515}) {
1616 return (
17 <div class="py-4">
18 <div class="flex items-center gap-2">
19 <label class="flex size-11 shrink-0 cursor-pointer items-center justify-center" title={t('ui.agreement.title')}>
17 <div className="py-4">
18 <div className="flex items-center gap-2">
19 <label
20 className="flex size-11 shrink-0 cursor-pointer items-center justify-center"
21 title={t('ui.agreement.title')}
22 >
2023 <input
2124 id="agreement"
2225 type="checkbox"
@@ -26,27 +29,27 @@
2629 aria-label={t('ui.agreement.aria')}
2730 aria-invalid={!!error}
2831 aria-describedby={error ? 'agreement-description' : undefined}
29 class="size-[18px] cursor-pointer appearance-none rounded border border-control-border bg-field checked:border-primary checked:bg-primary"
32 className="size-[18px] cursor-pointer appearance-none rounded border border-control-border bg-field checked:border-primary checked:bg-primary"
3033 />
31 {checked && <Check size={12} class="pointer-events-none absolute text-white" />}
34 {checked && <Check size={12} className="pointer-events-none absolute text-white" />}
3235 </label>
33 <div class="min-w-0 flex-1 text-xs leading-normal text-muted">
34 <label for="agreement" class="cursor-pointer">
36 <div className="min-w-0 flex-1 text-xs leading-normal text-muted">
37 <label htmlFor="agreement" className="cursor-pointer">
3538 {t('ui.agreement.label')}
3639 </label>
37 <div class="flex min-h-11 flex-wrap items-center gap-1">
38 <span class="text-accent-text">
40 <div className="flex min-h-11 flex-wrap items-center gap-1">
41 <span className="text-accent-text">
3942 <LegalLink kind="terms" />
4043 </span>
4144 <span>{t('ui.agreement.and')}</span>
42 <span class="text-accent-text">
45 <span className="text-accent-text">
4346 <LegalLink kind="privacy" />
4447 </span>
4548 </div>
4649 </div>
4750 </div>
4851 {error && (
49 <p id="agreement-description" class="text-xs text-danger">
52 <p id="agreement-description" className="text-xs text-danger">
5053 {error}
5154 </p>
5255 )}
Modified view/auth/src/shared/ui/alert.tsx View view/auth/src/shared/ui/alert.tsx
@@ -1,4 +1,4 @@
1import { AlertCircle, Check, Info } from 'lucide-preact'
1import { AlertCircle, Check, Info } from 'lucide-react'
22import type { Notice } from '@/shared/lib'
33
44export function Alert({ notice }: { notice?: Notice }) {
@@ -7,13 +7,13 @@
77 return (
88 <div
99 role={notice.kind === 'error' ? 'alert' : 'status'}
10 class={`mb-6 flex items-start gap-3 rounded-xl p-4 ${notice.kind === 'error' ? 'bg-error-bg' : notice.kind === 'success' ? 'bg-success-bg' : 'bg-elevated'}`}
10 className={`mb-6 flex items-start gap-3 rounded-xl p-4 ${notice.kind === 'error' ? 'bg-error-bg' : notice.kind === 'success' ? 'bg-success-bg' : 'bg-elevated'}`}
1111 >
1212 <Icon
1313 size={20}
14 class={`mt-0.5 shrink-0 ${notice.kind === 'error' ? 'text-danger' : notice.kind === 'success' ? 'text-success' : 'text-muted'}`}
14 className={`mt-0.5 shrink-0 ${notice.kind === 'error' ? 'text-danger' : notice.kind === 'success' ? 'text-success' : 'text-muted'}`}
1515 />
16 <p class="text-sm leading-[1.6] wrap-anywhere">{notice.text}</p>
16 <p className="text-sm leading-[1.6] wrap-anywhere">{notice.text}</p>
1717 </div>
1818 )
1919}
Modified view/auth/src/shared/ui/back-link.tsx View view/auth/src/shared/ui/back-link.tsx
@@ -1,4 +1,5 @@
1import { ArrowLeft } from 'lucide-preact'
1import { ArrowLeft } from 'lucide-react'
2import type { ReactNode } from 'react'
23import { routes } from '@/shared/config'
34import { t } from '@/shared/i18n'
45import { Link } from './link'
@@ -8,12 +9,12 @@
89 children = t('common.backToLogin'),
910}: {
1011 to?: string
11 children?: preact.ComponentChildren
12 children?: ReactNode
1213}) {
1314 return (
14 <div class="mt-6 flex justify-center">
15 <div className="mt-6 flex justify-center">
1516 <Link to={to}>
16 <ArrowLeft size={14} class="text-accent" />
17 <ArrowLeft size={14} className="text-accent" />
1718 {children}
1819 </Link>
1920 </div>
Modified view/auth/src/shared/ui/birth-date.tsx View view/auth/src/shared/ui/birth-date.tsx
@@ -1,4 +1,4 @@
1import { ChevronDown } from 'lucide-preact'
1import { ChevronDown } from 'lucide-react'
22import { t } from '@/shared/i18n'
33
44export interface BirthValue {
@@ -32,11 +32,11 @@
3232 ]
3333 const parts = ['year', 'month', 'day'] as const
3434 return (
35 <fieldset class="flex min-w-0 flex-col gap-2" disabled={disabled}>
36 <legend class="mb-2 text-sm font-medium">{t('ui.birth.legend')}</legend>
37 <div class="flex gap-2">
35 <fieldset className="flex min-w-0 flex-col gap-2" disabled={disabled}>
36 <legend className="mb-2 text-sm font-medium">{t('ui.birth.legend')}</legend>
37 <div className="flex gap-2">
3838 {parts.map((part, index) => (
39 <div key={part} class="relative min-w-0 flex-1">
39 <div key={part} className="relative min-w-0 flex-1">
4040 <select
4141 id={index === 0 ? 'birth' : `birth-${part}`}
4242 aria-label={t(
@@ -55,7 +55,7 @@
5555 next.day = ''
5656 onChange(next)
5757 }}
58 class={`h-12 w-full appearance-none rounded-lg border bg-field pr-8 pl-3 text-base outline-none focus:border-focus focus:ring-1 focus:ring-focus disabled:cursor-not-allowed disabled:bg-disabled ${value[part] ? 'text-ink' : 'text-muted'} ${error ? 'border-danger' : 'border-control-border'}`}
58 className={`h-12 w-full appearance-none rounded-lg border bg-field pr-8 pl-3 text-base outline-none focus:border-focus focus:ring-1 focus:ring-focus disabled:cursor-not-allowed disabled:bg-disabled ${value[part] ? 'text-ink' : 'text-muted'} ${error ? 'border-danger' : 'border-control-border'}`}
5959 >
6060 <option value="">
6161 {t(
@@ -73,11 +73,11 @@
7373 </option>
7474 ))}
7575 </select>
76 <ChevronDown size={16} class="pointer-events-none absolute top-4 right-3 text-icon" />
76 <ChevronDown size={16} className="pointer-events-none absolute top-4 right-3 text-icon" />
7777 </div>
7878 ))}
7979 </div>
80 <p id="birth-description" class={`text-xs leading-normal ${error ? 'text-danger' : 'text-muted'}`}>
80 <p id="birth-description" className={`text-xs leading-normal ${error ? 'text-danger' : 'text-muted'}`}>
8181 {error || helper}
8282 </p>
8383 </fieldset>
Modified view/auth/src/shared/ui/button.tsx View view/auth/src/shared/ui/button.tsx
@@ -1,5 +1,5 @@
1import { LoaderCircle } from 'lucide-preact'
2import type { ComponentChildren } from 'preact'
1import { LoaderCircle } from 'lucide-react'
2import type { ReactNode } from 'react'
33
44export function Button({
55 children,
@@ -9,7 +9,7 @@
99 type = 'submit',
1010 secondary = false,
1111}: {
12 children: ComponentChildren
12 children: ReactNode
1313 busy?: boolean
1414 disabled?: boolean
1515 onClick?: () => void
@@ -22,9 +22,9 @@
2222 onClick={onClick}
2323 disabled={disabled || busy}
2424 aria-busy={busy}
25 class={`flex min-h-12 w-full items-center justify-center gap-2 rounded-lg px-4 text-base font-semibold tracking-[0.5px] transition-colors disabled:cursor-not-allowed ${secondary ? 'border border-control-border bg-field text-ink hover:bg-elevated' : 'bg-primary text-white hover:bg-primary-hover active:bg-primary-pressed disabled:bg-disabled disabled:text-disabled-ink'}`}
25 className={`flex min-h-12 w-full items-center justify-center gap-2 rounded-lg px-4 text-base font-semibold tracking-[0.5px] transition-colors disabled:cursor-not-allowed ${secondary ? 'border border-control-border bg-field text-ink hover:bg-elevated' : 'bg-primary text-white hover:bg-primary-hover active:bg-primary-pressed disabled:bg-disabled disabled:text-disabled-ink'}`}
2626 >
27 {busy && <LoaderCircle size={18} class="animate-spin motion-reduce:animate-none" />}
27 {busy && <LoaderCircle size={18} className="animate-spin motion-reduce:animate-none" />}
2828 {children}
2929 </button>
3030 )
Modified view/auth/src/shared/ui/field.tsx View view/auth/src/shared/ui/field.tsx
@@ -1,5 +1,5 @@
1import { AlertCircle, Eye, EyeOff, KeyRound, LockKeyhole, Mail, UserRound } from 'lucide-preact'
2import { useState } from 'preact/hooks'
1import { AlertCircle, Eye, EyeOff, KeyRound, LockKeyhole, Mail, UserRound } from 'lucide-react'
2import { useState } from 'react'
33import { t } from '@/shared/i18n'
44
55export interface FieldProps {
@@ -39,30 +39,30 @@
3939 const Icon = fieldIcons[icon]
4040 const description = error || helper
4141 return (
42 <div class="flex min-w-0 flex-col gap-2">
43 <label for={name} class="text-sm leading-normal font-medium">
42 <div className="flex min-w-0 flex-col gap-2">
43 <label htmlFor={name} className="text-sm leading-normal font-medium">
4444 {label}
4545 </label>
4646 <div
47 class={`flex h-12 items-center gap-3 rounded-lg border bg-field pl-3 transition-colors focus-within:border-focus ${error ? 'border-danger' : 'border-control-border'} ${disabled ? 'bg-disabled text-disabled-ink' : ''}`}
47 className={`flex h-12 items-center gap-3 rounded-lg border bg-field pl-3 transition-colors focus-within:border-focus ${error ? 'border-danger' : 'border-control-border'} ${disabled ? 'bg-disabled text-disabled-ink' : ''}`}
4848 >
49 <Icon size={18} class="shrink-0 text-icon" strokeWidth={1.6} />
49 <Icon size={18} className="shrink-0 text-icon" strokeWidth={1.6} />
5050 <input
5151 id={name}
5252 name={name}
5353 type={type === 'password' && show ? 'text' : type}
5454 value={value}
55 onInput={(event) => onInput?.(event.currentTarget.value)}
55 onChange={(event) => onInput?.(event.currentTarget.value)}
5656 placeholder={placeholder}
5757 autoComplete={autoComplete}
5858 disabled={disabled}
5959 readOnly={readOnly}
6060 maxLength={maxLength}
6161 autoCapitalize="none"
62 spellcheck={false}
62 spellCheck={false}
6363 aria-invalid={!!error}
6464 aria-describedby={description ? `${name}-description` : undefined}
65 class={`h-full min-w-0 flex-1 border-0 bg-transparent text-base text-ink outline-none placeholder:text-muted disabled:cursor-not-allowed ${type === 'email' ? 'font-latin' : ''} ${type === 'password' ? '' : 'pr-3'}`}
65 className={`h-full min-w-0 flex-1 border-0 bg-transparent text-base text-ink outline-none placeholder:text-muted disabled:cursor-not-allowed ${type === 'email' ? 'font-latin' : ''} ${type === 'password' ? '' : 'pr-3'}`}
6666 />
6767 {type === 'password' && (
6868 <button
@@ -71,7 +71,7 @@
7171 aria-label={show ? t('ui.field.conceal', { label }) : t('ui.field.reveal', { label })}
7272 aria-pressed={show}
7373 onClick={() => setShow(!show)}
74 class="mr-px flex size-11 shrink-0 items-center justify-center rounded-md text-icon hover:text-ink"
74 className="mr-px flex size-11 shrink-0 items-center justify-center rounded-md text-icon hover:text-ink"
7575 >
7676 {show ? <Eye size={18} strokeWidth={1.6} /> : <EyeOff size={18} strokeWidth={1.6} />}
7777 </button>
@@ -80,9 +80,9 @@
8080 {description && (
8181 <p
8282 id={`${name}-description`}
83 class={`flex items-start gap-1 text-xs leading-normal ${error ? 'text-danger' : 'text-muted'}`}
83 className={`flex items-start gap-1 text-xs leading-normal ${error ? 'text-danger' : 'text-muted'}`}
8484 >
85 {error && <AlertCircle size={14} class="mt-0.5 shrink-0" />}
85 {error && <AlertCircle size={14} className="mt-0.5 shrink-0" />}
8686 {description}
8787 </p>
8888 )}
Modified view/auth/src/shared/ui/flow-required.tsx View view/auth/src/shared/ui/flow-required.tsx
@@ -1,4 +1,4 @@
1import type { LucideIcon } from 'lucide-preact'
1import type { LucideIcon } from 'lucide-react'
22import { navigate } from '@/shared/lib'
33import { Button } from './button'
44import { Heading } from './heading'
Modified view/auth/src/shared/ui/form-navigation.tsx View view/auth/src/shared/ui/form-navigation.tsx
@@ -2,8 +2,8 @@
22
33export function FormNavigation({ prompt, action, to }: { prompt: string; action: string; to: string }) {
44 return (
5 <div class="mt-6 flex items-center justify-center gap-1 text-xs">
6 <span class="text-muted">{prompt}</span>
5 <div className="mt-6 flex items-center justify-center gap-1 text-xs">
6 <span className="text-muted">{prompt}</span>
77 <Link to={to}>{action}</Link>
88 </div>
99 )
Modified view/auth/src/shared/ui/heading.tsx View view/auth/src/shared/ui/heading.tsx
@@ -1,5 +1,5 @@
1import type { LucideIcon } from 'lucide-preact'
2import type { ComponentChildren } from 'preact'
1import type { LucideIcon } from 'lucide-react'
2import type { ReactNode } from 'react'
33
44export function Heading({
55 title,
@@ -9,7 +9,7 @@
99 success = false,
1010}: {
1111 title: string
12 description?: ComponentChildren
12 description?: ReactNode
1313 step?: string
1414 icon?: LucideIcon
1515 success?: boolean
@@ -17,23 +17,23 @@
1717 return (
1818 <>
1919 {Icon && (
20 <div class="mb-6 flex justify-center">
20 <div className="mb-6 flex justify-center">
2121 <div
22 class={`flex size-14 items-center justify-center rounded-full ${success ? 'bg-success-bg text-success' : 'bg-accent-soft text-accent'}`}
22 className={`flex size-14 items-center justify-center rounded-full ${success ? 'bg-success-bg text-success' : 'bg-accent-soft text-accent'}`}
2323 >
2424 <Icon size={26} strokeWidth={1.8} />
2525 </div>
2626 </div>
2727 )}
28 <div class="mb-8 flex flex-col items-center gap-2 text-center">
29 {step && <p class="text-xs leading-normal text-muted">{step}</p>}
28 <div className="mb-8 flex flex-col items-center gap-2 text-center">
29 {step && <p className="text-xs leading-normal text-muted">{step}</p>}
3030 <h1
3131 tabIndex={-1}
32 class="w-full text-2xl leading-[1.3] font-semibold tracking-[-0.4px] outline-none md:text-[28px]"
32 className="w-full text-2xl leading-[1.3] font-semibold tracking-[-0.4px] outline-none md:text-[28px]"
3333 >
3434 {title}
3535 </h1>
36 {description && <p class="w-full text-base leading-[1.6] whitespace-pre-line text-muted">{description}</p>}
36 {description && <p className="w-full text-base leading-[1.6] whitespace-pre-line text-muted">{description}</p>}
3737 </div>
3838 </>
3939 )
Modified view/auth/src/shared/ui/legal-link.tsx View view/auth/src/shared/ui/legal-link.tsx
@@ -1,4 +1,4 @@
1import { useRef } from 'preact/hooks'
1import { useRef } from 'react'
22import { t } from '@/shared/i18n'
33import { Button } from './button'
44
@@ -12,7 +12,7 @@
1212 href={url}
1313 target="_blank"
1414 rel="noopener noreferrer"
15 class="inline-flex min-h-11 items-center px-0.5 hover:underline"
15 className="inline-flex min-h-11 items-center px-0.5 hover:underline"
1616 >
1717 {title}
1818 </a>
@@ -21,14 +21,14 @@
2121 <>
2222 <button
2323 type="button"
24 class="inline-flex min-h-11 items-center px-0.5 hover:underline"
24 className="inline-flex min-h-11 items-center px-0.5 hover:underline"
2525 onClick={() => ref.current?.showModal()}
2626 >
2727 {title}
2828 </button>
2929 <dialog
3030 ref={ref}
31 class="m-auto w-[calc(100%-32px)] max-w-sm rounded-xl border border-line bg-page p-6 text-ink shadow-xl backdrop:bg-black/40"
31 className="m-auto w-[calc(100%-32px)] max-w-sm rounded-xl border border-line bg-page p-6 text-ink shadow-xl backdrop:bg-black/40"
3232 aria-label={title}
3333 onMouseDown={(event) => {
3434 const dialog = ref.current
@@ -44,8 +44,8 @@
4444 }
4545 }}
4646 >
47 <h2 class="mb-3 text-lg font-semibold">{title}</h2>
48 <p class="mb-6 text-sm leading-relaxed text-muted">{t('ui.legal.missing', { title })}</p>
47 <h2 className="mb-3 text-lg font-semibold">{title}</h2>
48 <p className="mb-6 text-sm leading-relaxed text-muted">{t('ui.legal.missing', { title })}</p>
4949 <Button type="button" onClick={() => ref.current?.close()}>
5050 {t('common.close')}
5151 </Button>
Modified view/auth/src/shared/ui/link.tsx View view/auth/src/shared/ui/link.tsx
@@ -1,19 +1,11 @@
1import type { ComponentChildren } from 'preact'
1import type { ReactNode } from 'react'
22import { navigate, navigationHref } from '@/shared/lib'
33
4export function Link({
5 to,
6 children,
7 className = '',
8}: {
9 to: string
10 children: ComponentChildren
11 className?: string
12}) {
4export function Link({ to, children, className = '' }: { to: string; children: ReactNode; className?: string }) {
135 return (
146 <a
157 href={navigationHref(to)}
16 class={`inline-flex min-h-11 items-center justify-center gap-2 px-0.5 text-xs font-medium text-accent-text hover:underline ${className}`}
8 className={`inline-flex min-h-11 items-center justify-center gap-2 px-0.5 text-xs font-medium text-accent-text hover:underline ${className}`}
179 onClick={(event) => {
1810 if (event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
1911 // 只有同源 http(s) 链接才拦截:外链、mailto/tel 与自定义协议交给浏览器原生行为。
Modified view/auth/src/shared/ui/otp.tsx View view/auth/src/shared/ui/otp.tsx
@@ -1,4 +1,4 @@
1import { useState } from 'preact/hooks'
1import { useState } from 'react'
22import { t } from '@/shared/i18n'
33
44export function Otp({
@@ -17,18 +17,18 @@
1717 const [focused, setFocused] = useState(false)
1818 const [position, setPosition] = useState(0)
1919 return (
20 <div class="flex flex-col gap-2">
21 <label class="text-sm font-medium" for="code">
20 <div className="flex flex-col gap-2">
21 <label className="text-sm font-medium" htmlFor="code">
2222 {label}
2323 </label>
24 <div class="relative h-14">
25 <div class="pointer-events-none flex h-full gap-1" aria-hidden="true">
24 <div className="relative h-14">
25 <div className="pointer-events-none flex h-full gap-1" aria-hidden="true">
2626 {Array.from({ length: 6 }, (_, index) => (
2727 <span
2828 key={index}
29 class={`flex h-full min-w-0 flex-1 items-center justify-center rounded-lg border bg-field font-latin text-2xl font-medium ${error ? 'border-danger' : focused && index === position ? 'border-focus ring-1 ring-focus' : 'border-control-border'}`}
29 className={`flex h-full min-w-0 flex-1 items-center justify-center rounded-lg border bg-field font-latin text-2xl font-medium ${error ? 'border-danger' : focused && index === position ? 'border-focus ring-1 ring-focus' : 'border-control-border'}`}
3030 >
31 {value[index] || (focused && index === position ? <span class="h-6 w-0.5 bg-focus" /> : '')}
31 {value[index] || (focused && index === position ? <span className="h-6 w-0.5 bg-focus" /> : '')}
3232 </span>
3333 ))}
3434 </div>
@@ -46,7 +46,7 @@
4646 onFocus={() => setFocused(true)}
4747 onBlur={() => setFocused(false)}
4848 onSelect={(event) => setPosition(Math.min(event.currentTarget.selectionStart ?? value.length, 5))}
49 onInput={(event) => {
49 onChange={(event) => {
5050 const next = event.currentTarget.value.replace(/\D/g, '').slice(0, 6)
5151 onInput(next)
5252 setPosition(Math.min(next.length, 5))
@@ -59,10 +59,10 @@
5959 setPosition(Math.min(next.length, 5))
6060 }
6161 }}
62 class="absolute inset-0 h-full w-full cursor-text border-0 bg-transparent text-transparent caret-transparent opacity-[0.01] outline-none"
62 className="absolute inset-0 h-full w-full cursor-text border-0 bg-transparent text-transparent caret-transparent opacity-[0.01] outline-none"
6363 />
6464 </div>
65 <p id="code-description" class={`text-xs leading-normal ${error ? 'text-danger' : 'text-muted'}`}>
65 <p id="code-description" className={`text-xs leading-normal ${error ? 'text-danger' : 'text-muted'}`}>
6666 {error || helper}
6767 </p>
6868 </div>
Modified view/auth/src/shared/ui/resend-button.tsx View view/auth/src/shared/ui/resend-button.tsx
@@ -1,4 +1,4 @@
1import type { ComponentChildren } from 'preact'
1import type { ReactNode } from 'react'
22import { t } from '@/shared/i18n'
33
44export function ResendButton({
@@ -10,16 +10,16 @@
1010 seconds: number
1111 busy: boolean
1212 onClick: () => void
13 children?: ComponentChildren
13 children?: ReactNode
1414}) {
1515 return (
16 <div class="flex min-h-11 items-center justify-center gap-1 text-xs">
17 <span class="text-muted">{t('ui.resend.prompt')}</span>
16 <div className="flex min-h-11 items-center justify-center gap-1 text-xs">
17 <span className="text-muted">{t('ui.resend.prompt')}</span>
1818 <button
1919 type="button"
2020 disabled={seconds > 0 || busy}
2121 onClick={onClick}
22 class="inline-flex min-h-11 items-center justify-center px-1 text-accent-text hover:underline disabled:text-muted disabled:no-underline"
22 className="inline-flex min-h-11 items-center justify-center px-1 text-accent-text hover:underline disabled:text-muted disabled:no-underline"
2323 >
2424 {busy ? t('common.sending') : seconds > 0 ? t('ui.resend.wait', { seconds }) : children}
2525 </button>
Modified view/auth/src/shared/ui/retry-hint.tsx View view/auth/src/shared/ui/retry-hint.tsx
@@ -2,7 +2,7 @@
22
33export function RetryHint({ seconds }: { seconds: number }) {
44 return seconds > 0 ? (
5 <p role="status" class="mt-3 text-center text-xs text-muted">
5 <p role="status" className="mt-3 text-center text-xs text-muted">
66 {t('ui.retry', { seconds })}
77 </p>
88 ) : null
Modified view/auth/src/widgets/app-shell/ui/app-shell.tsx View view/auth/src/widgets/app-shell/ui/app-shell.tsx
@@ -1,18 +1,18 @@
1import type { ComponentChildren } from 'preact'
1import type { ReactNode } from 'react'
22import { ThemeSwitch, useTheme } from '@/features/theme-toggle'
33import { routes } from '@/shared/config'
44import { t } from '@/shared/i18n'
55import { navigate, navigationHref } from '@/shared/lib'
66import { LegalLink } from '@/shared/ui'
77
8export function AppShell({ children }: { children: ComponentChildren }) {
8export function AppShell({ children }: { children: ReactNode }) {
99 const theme = useTheme()
1010 return (
11 <div class="flex min-h-dvh flex-col bg-page px-4 pt-6 pb-[max(28px,env(safe-area-inset-bottom))] text-ink md:px-12 md:pt-10 md:pb-8 lg:px-16 lg:pt-12 lg:pb-10">
12 <header class="flex h-10 shrink-0 items-center justify-between">
11 <div className="flex min-h-dvh flex-col bg-page px-4 pt-6 pb-[max(28px,env(safe-area-inset-bottom))] text-ink md:px-12 md:pt-10 md:pb-8 lg:px-16 lg:pt-12 lg:pb-10">
12 <header className="flex h-10 shrink-0 items-center justify-between">
1313 <a
1414 href={navigationHref(routes.home)}
15 class="inline-flex min-h-11 items-center gap-2.75"
15 className="inline-flex min-h-11 items-center gap-2.75"
1616 aria-label={t('shell.home')}
1717 onClick={(event) => {
1818 if (event.button || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return
@@ -21,17 +21,17 @@
2121 }}
2222 >
2323 <img src="/media/gitdataai-red-panda.png" width={40} height={40} alt="" />
24 <span class="font-latin text-[19px] font-semibold tracking-[-0.4px]">GitDataAI</span>
24 <span className="font-latin text-[19px] font-semibold tracking-[-0.4px]">GitDataAI</span>
2525 </a>
2626 <ThemeSwitch model={theme} />
2727 </header>
28 <main id="main" class="flex flex-1 flex-col items-center justify-center py-6 md:py-8">
29 <section class="w-full max-w-[400px]">{children}</section>
28 <main id="main" className="flex flex-1 flex-col items-center justify-center py-6 md:py-8">
29 <section className="w-full max-w-[400px]">{children}</section>
3030 </main>
31 <footer class="flex shrink-0 flex-wrap items-center justify-center gap-x-3 text-xs leading-normal text-muted">
31 <footer className="flex shrink-0 flex-wrap items-center justify-center gap-x-3 text-xs leading-normal text-muted">
3232 <LegalLink kind="terms" />
3333 <LegalLink kind="privacy" />
34 <span class="font-latin">© 2026 GitDataAI</span>
34 <span className="font-latin">© 2026 GitDataAI</span>
3535 </footer>
3636 </div>
3737 )
Modified openapi.json View openapi.json

Diff too large to display.