refactor: replace Preact with React imports for improved consistency across components
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified lib/auth/src/handlers/identity/login/service.rs lib/auth/src/handlers/identity/login/service.rs +1 −1 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 lib/auth/src/handlers/identity/logout/service.rs +1 −1 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 lib/auth/src/handlers/identity/me/handler.rs +1 −1 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 lib/auth/src/handlers/identity/me/mod.rs +0 −5 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 lib/auth/src/handlers/identity/me/service.rs +0 −75
@@ -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 lib/auth/src/handlers/identity/me/types.rs +0 −33
@@ -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 lib/auth/src/handlers/identity/organization/error.rs +0 −22
@@ -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 lib/auth/src/handlers/identity/organization/handler.rs +1 −1 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 lib/auth/src/handlers/identity/organization/invite.rs +0 −480
@@ -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 lib/auth/src/handlers/identity/organization/join.rs +0 −486
@@ -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 lib/auth/src/handlers/identity/organization/member.rs +0 −939
@@ -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 lib/auth/src/handlers/identity/organization/mod.rs +0 −10 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 lib/auth/src/handlers/identity/organization/permission.rs +0 −324
@@ -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 lib/auth/src/handlers/identity/organization/role.rs +0 −868
@@ -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 lib/auth/src/handlers/identity/organization/service.rs +0 −1036
@@ -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 lib/auth/src/handlers/identity/organization/types.rs +0 −422
@@ -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 lib/auth/src/handlers/identity/ssh_key/handler.rs +1 −1 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 lib/auth/src/handlers/identity/ssh_key/mod.rs +0 −4 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 lib/auth/src/handlers/identity/ssh_key/service.rs +0 −294
@@ -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 lib/auth/src/handlers/identity/ssh_key/types.rs +0 −50
@@ -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 lib/auth/OpenApi.Auth.json +195 −1394 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 lib/git/src/handler/tag_protection/create_policy.rs +1 −0 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 lib/git/src/handler/tag_protection/delete_policy.rs +1 −0 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 lib/git/src/handler/tag_protection/get_policy.rs +1 −0 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 lib/git/src/handler/tag_protection/list_policies.rs +1 −0 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 lib/git/src/handler/tag_protection/update_policy.rs +1 −0 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 lib/git/src/openapi.rs +0 −3 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/auth/src/app/routing/router.tsx +20 −8 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/auth/src/app/app.tsx +1 −1 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/auth/src/app/main.tsx +2 −2 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/auth/src/entities/auth-callback/model/callback-url.ts +7 −3 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/auth/src/entities/session/model/use-session.ts +1 −1 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/auth/src/features/login-by-code/model/use-login-by-code.ts +1 −1 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/auth/src/features/login-by-code/ui/login-code-form.tsx +6 −6 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/auth/src/features/login-by-password/model/use-login-by-password.ts +1 −1 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/auth/src/features/login-by-password/ui/login-form.tsx +2 −2 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/auth/src/features/register-account/model/use-register-account.ts +1 −1 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/auth/src/features/register-account/model/use-register-verify.ts +1 −1 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/auth/src/features/register-account/ui/register-form.tsx +1 −1 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/auth/src/features/register-account/ui/register-verify-form.tsx +3 −3 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/auth/src/features/request-password-reset/model/use-request-password-reset.ts +1 −1 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/auth/src/features/request-password-reset/ui/forgot-password-form.tsx +1 −1 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/auth/src/features/reset-password/model/use-reset-password.ts +1 −1 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/auth/src/features/reset-password/ui/reset-password-form.tsx +2 −2 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/auth/src/features/theme-toggle/model/use-theme.ts +1 −1 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/auth/src/features/theme-toggle/ui/theme-switch.tsx +2 −2 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/auth/src/features/verify-mfa/model/use-verify-mfa.ts +1 −1 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/auth/src/features/verify-mfa/ui/mfa-form.tsx +3 −3 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/auth/src/pages/account/ui/account-page.tsx +14 −9 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/auth/src/pages/forgot-password/ui/forgot-password-page.tsx +1 −1 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/auth/src/pages/login-verify/ui/login-verify-page.tsx +1 −1 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/auth/src/pages/register-verify/ui/register-verify-page.tsx +3 −3 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/auth/src/pages/reset-complete/ui/reset-complete-page.tsx +2 −2 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/auth/src/pages/reset-password/ui/reset-password-page.tsx +3 −3 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/auth/src/pages/reset-sent/ui/reset-sent-page.tsx +6 −6 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/auth/src/shared/api/auth/auth.schemas.ts +8 −236 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/auth/src/shared/api/auth/auth.ts +21 −470 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/auth/src/shared/i18n/locale.ts +1 −1 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/auth/src/shared/i18n/messages.ts +0 −2 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/auth/src/shared/lib/http/http.ts +16 −8 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/auth/src/shared/lib/navigation.ts +1 −1 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/auth/src/shared/lib/store.ts +1 −1 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/auth/src/shared/lib/use-countdown.ts +1 −1 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/auth/src/shared/lib/use-task.ts +1 −1 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/auth/src/shared/ui/agreement.tsx +15 −12 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/auth/src/shared/ui/alert.tsx +4 −4 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/auth/src/shared/ui/back-link.tsx +5 −4 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/auth/src/shared/ui/birth-date.tsx +8 −8 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/auth/src/shared/ui/button.tsx +5 −5 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/auth/src/shared/ui/field.tsx +12 −12 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/auth/src/shared/ui/flow-required.tsx +1 −1 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/auth/src/shared/ui/form-navigation.tsx +2 −2 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/auth/src/shared/ui/heading.tsx +9 −9 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/auth/src/shared/ui/legal-link.tsx +6 −6 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/auth/src/shared/ui/link.tsx +3 −11 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/auth/src/shared/ui/otp.tsx +10 −10 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/auth/src/shared/ui/resend-button.tsx +5 −5 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/auth/src/shared/ui/retry-hint.tsx +1 −1 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/auth/src/widgets/app-shell/ui/app-shell.tsx +10 −10 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 openapi.json View openapi.json
Diff too large to display.