feat(space): add endpoint to retrieve space image file and update related routes
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified admin/src/pages/login.tsx admin/src/pages/login.tsx +4 −4 View admin/src/pages/login.tsx
@@ -29,7 +29,7 @@
2929 toast('登录成功')
3030 navigate(resp.must_change_password ? '/password' : '/', { replace: true })
3131 } catch (err) {
32 setError(err instanceof ApiError ? err.message : '登录失败,请稍后再试')
32 setError(err instanceof ApiError ? err.message : '登录失败,请稍后重试。')
3333 } finally {
3434 setBusy(false)
3535 }
@@ -37,7 +37,7 @@
3737
3838 return (
3939 <div className="flex min-h-full items-center justify-center p-4">
40 <Card title="GitData 管理后台" className="w-full max-w-sm">
40 <Card title="GitDataAI 管理后台" className="w-full max-w-sm">
4141 <form
4242 className="space-y-3"
4343 onSubmit={(event) => {
@@ -49,14 +49,14 @@
4949 <Field label="管理员账号">
5050 <TextInput value={username} onChange={setUsername} placeholder="username" />
5151 </Field>
52 <Field label="口令">
52 <Field label="密码">
5353 <TextInput value={password} onChange={setPassword} type="password" />
5454 </Field>
5555 <Button type="submit" disabled={busy || !username || !password}>
5656 {busy ? '登录中…' : '登录'}
5757 </Button>
5858 <p className="text-xs text-slate-500">
59 管理端使用独立会话 Cookie,与普通用户登录互不影响。
59 管理端使用独立会话,与普通用户登录互不影响。
6060 </p>
6161 </form>
6262 </Card>
Modified config/default.toml config/default.toml +3 −1 View config/default.toml
@@ -85,7 +85,9 @@
8585cookie_http_only = true
8686cookie_secure = false
8787cookie_same_site = "lax"
88cookie_max_age_secs = 604800
88# Long enough to serve the 30-day "keep me signed in" window
89# (max_lifespan_secs); non-remember logins get a session cookie.
90cookie_max_age_secs = 2592000
8991cookie_prefix_host = false
9092cookie_encode_ip_and_user_agent = false
9193lifespan_secs = 604800
Modified lib/admin/src/errors.rs lib/admin/src/errors.rs +18 −0 View lib/admin/src/errors.rs
@@ -54,6 +54,24 @@
5454 auth::errors::AuthError::PowFailed => {
5555 Self::BadRequest("proof of work failed".to_string())
5656 }
57 auth::errors::AuthError::TwoFactorExpired => {
58 Self::BadRequest("two-factor challenge expired".to_string())
59 }
60 auth::errors::AuthError::TwoFactorCodeInvalid => {
61 Self::BadRequest("invalid two-factor code".to_string())
62 }
63 auth::errors::AuthError::RecoveryCodeInvalid => {
64 Self::BadRequest("invalid or used recovery code".to_string())
65 }
66 auth::errors::AuthError::VerificationCodeExpired => {
67 Self::BadRequest("verification code expired".to_string())
68 }
69 auth::errors::AuthError::VerificationCodeInvalid => {
70 Self::BadRequest("invalid verification code".to_string())
71 }
72 auth::errors::AuthError::ResetTokenInvalid => {
73 Self::BadRequest("invalid or expired reset token".to_string())
74 }
5775 auth::errors::AuthError::Internal(message) => Self::Internal(message),
5876 }
5977 }
Modified lib/auth/src/services/email.rs lib/auth/src/services/email.rs +4 −12 View lib/auth/src/services/email.rs
@@ -147,9 +147,7 @@
147147 let purpose_name = purpose_str(cmd.purpose);
148148 let code_key = format!("auth:code:{purpose_name}:{}", cmd.temp_id);
149149 let Some(stored) = export::redis::get_string(self.redis(), &code_key).await? else {
150 return Err(AuthError::BadRequest(
151 "verification code expired or not found".to_string(),
152 ));
150 return Err(AuthError::VerificationCodeExpired);
153151 };
154152 let attempts_key = format!("auth:code_attempts:{purpose_name}:{}", cmd.temp_id);
155153 if !attempts_track(
@@ -165,9 +163,7 @@
165163 }
166164 let provided = cmd.code.trim().to_string();
167165 if !constant_time_eq(stored.as_bytes(), provided.as_bytes()) {
168 return Err(AuthError::BadRequest(
169 "invalid verification code".to_string(),
170 ));
166 return Err(AuthError::VerificationCodeInvalid);
171167 }
172168 export::redis::delete(self.redis(), &code_key).await?;
173169 attempts_reset(self.redis(), &attempts_key).await?;
@@ -185,14 +181,10 @@
185181 .one(db.write())
186182 .await?
187183 else {
188 return Err(AuthError::BadRequest(
189 "invalid verification code".to_string(),
190 ));
184 return Err(AuthError::VerificationCodeInvalid);
191185 };
192186 if row.expires_at <= OffsetDateTime::now_utc() {
193 return Err(AuthError::BadRequest(
194 "verification code expired".to_string(),
195 ));
187 return Err(AuthError::VerificationCodeExpired);
196188 }
197189
198190 let now = OffsetDateTime::now_utc();
Modified lib/auth/src/services/login.rs lib/auth/src/services/login.rs +25 −7 View lib/auth/src/services/login.rs
@@ -24,6 +24,10 @@
2424 pub identifier: String,
2525 pub password: String,
2626 pub pow_answer: String,
27 /// Opt-in long-lived session ("keep me signed in"). Defaults to false so
28 /// older clients keep the standard lifespan.
29 #[serde(default)]
30 pub remember: bool,
2731}
2832
2933#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -46,6 +50,9 @@
4650 pub expires_at: i64,
4751 pub ua_hash: Option<String>,
4852 pub ip_hash: Option<String>,
53 /// Sessions written before this field existed must still decode.
54 #[serde(default)]
55 pub remember: bool,
4956}
5057
5158impl AuthService {
@@ -106,6 +113,7 @@
106113 expires_at: now_secs() + TWO_FA_TTL_SECS,
107114 ua_hash,
108115 ip_hash,
116 remember: cmd.remember,
109117 };
110118 session.set(TWO_FA_KEY, &challenge);
111119 return Ok(LoginResp {
@@ -115,7 +123,8 @@
115123 });
116124 }
117125
118 self.finish_login(session, &user, ua_hash, ip_hash).await
126 self.finish_login(session, &user, ua_hash, ip_hash, cmd.remember)
127 .await
119128 }
120129
121130 pub async fn login_2fa(
@@ -142,9 +151,7 @@
142151
143152 if challenge.expires_at <= now_secs() {
144153 session.remove(TWO_FA_KEY);
145 return Err(AuthError::BadRequest(
146 "two-factor challenge expired".to_string(),
147 ));
154 return Err(AuthError::TwoFactorExpired);
148155 }
149156
150157 let db = self.db();
@@ -167,8 +174,14 @@
167174 self.consume_two_factor_code(&user, &cmd.code).await?;
168175 session.remove(TWO_FA_KEY);
169176 attempts_reset(self.redis(), &attempts_key).await?;
170 self.finish_login(session, &user, challenge.ua_hash, challenge.ip_hash)
171 .await
177 self.finish_login(
178 session,
179 &user,
180 challenge.ua_hash,
181 challenge.ip_hash,
182 challenge.remember,
183 )
184 .await
172185 }
173186
174187 async fn consume_two_factor_code(
@@ -217,7 +230,7 @@
217230 .one(db.write())
218231 .await?
219232 else {
220 return Err(AuthError::BadRequest("invalid or expired code".to_string()));
233 return Err(AuthError::RecoveryCodeInvalid);
221234 };
222235 let mut active = row.into_active_model();
223236 active.used_at = Set(Some(OffsetDateTime::now_utc()));
@@ -231,6 +244,7 @@
231244 user: &user::Model,
232245 ua_hash: Option<String>,
233246 ip_hash: Option<String>,
247 remember: bool,
234248 ) -> Result<LoginResp, AuthError> {
235249 self.assert_not_suspended(user.uid).await?;
236250 let db = self.db();
@@ -260,8 +274,12 @@
260274 ua_hash.as_deref(),
261275 ip_hash.as_deref(),
262276 device_id,
277 remember,
263278 )
264279 .await?;
280 // Marks the session row and the browser cookie for the long window;
281 // without it both expire on the standard lifespan.
282 session.set_longterm(remember);
265283
266284 let auth_user = AuthUser {
267285 uid: user.uid,
Modified lib/auth/src/services/password.rs lib/auth/src/services/password.rs +3 −5 View lib/auth/src/services/password.rs
@@ -121,9 +121,7 @@
121121
122122 let reset_key = format!("auth:reset:{}", cmd.temp_id);
123123 let Some(raw) = export::redis::getdel_string(self.redis(), &reset_key).await? else {
124 return Err(AuthError::BadRequest(
125 "reset token expired or not found".to_string(),
126 ));
124 return Err(AuthError::ResetTokenInvalid);
127125 };
128126 let stored: StoredResetToken = serde_json::from_str(&raw)
129127 .map_err(|error| AuthError::Internal(format!("decode reset token: {error}")))?;
@@ -134,7 +132,7 @@
134132 sha256_b64url(provided.as_bytes()).as_bytes(),
135133 ) && stored.email == lower_email;
136134 if !valid {
137 return Err(AuthError::BadRequest("invalid reset token".to_string()));
135 return Err(AuthError::ResetTokenInvalid);
138136 }
139137
140138 let db = self.db();
@@ -145,7 +143,7 @@
145143 .one(db.write())
146144 .await?
147145 else {
148 return Err(AuthError::BadRequest("invalid reset token".to_string()));
146 return Err(AuthError::ResetTokenInvalid);
149147 };
150148
151149 let password_hash = hash_password(&cmd.new_password)?;
Modified lib/auth/src/services/sessions.rs lib/auth/src/services/sessions.rs +11 −3 View lib/auth/src/services/sessions.rs
@@ -10,8 +10,15 @@
1010const DEFAULT_SESSION_LIFETIME: Duration = Duration::days(30);
1111
1212impl AuthService {
13 pub fn session_lifetime(&self) -> Duration {
14 match self.config().session.lifespan_secs {
13 /// Session lifetime for a fresh login: "remember me" gets the long-term
14 /// window, everything else the standard lifespan.
15 pub fn session_lifetime(&self, remember: bool) -> Duration {
16 let configured = if remember {
17 self.config().session.max_lifespan_secs
18 } else {
19 self.config().session.lifespan_secs
20 };
21 match configured {
1522 Some(secs) if secs > 0 => Duration::seconds(secs),
1623 _ => DEFAULT_SESSION_LIFETIME,
1724 }
@@ -24,6 +31,7 @@
2431 ua_hash: Option<&str>,
2532 ip_hash: Option<&str>,
2633 device_id: Option<Uuid>,
34 remember: bool,
2735 ) -> Result<(), AuthError> {
2836 let now = OffsetDateTime::now_utc();
2937 let id = Uuid::now_v7();
@@ -41,7 +49,7 @@
4149 revoked_at: Set(None),
4250 last_seen_at: Set(now),
4351 created_at: Set(now),
44 expires_at: Set(now + self.session_lifetime()),
52 expires_at: Set(now + self.session_lifetime(remember)),
4553 replaced_at: Set(None),
4654 grace_expires_at: Set(None),
4755 updated_at: Set(now),
Modified lib/auth/src/share/totp.rs lib/auth/src/share/totp.rs +1 −3 View lib/auth/src/share/totp.rs
@@ -33,9 +33,7 @@
3333impl From<TotpError> for AuthError {
3434 fn from(error: TotpError) -> Self {
3535 match error {
36 TotpError::InvalidCode | TotpError::InvalidOrExpiredCode => {
37 Self::BadRequest("invalid or expired code".to_string())
38 }
36 TotpError::InvalidCode | TotpError::InvalidOrExpiredCode => Self::TwoFactorCodeInvalid,
3937 TotpError::MalformedSecret => Self::Internal("malformed totp secret".to_string()),
4038 }
4139 }
Modified lib/auth/src/errors.rs lib/auth/src/errors.rs +42 −0 View lib/auth/src/errors.rs
@@ -13,6 +13,12 @@
1313 RateLimited,
1414 PowFailed,
1515 Internal(String),
16 TwoFactorExpired,
17 TwoFactorCodeInvalid,
18 RecoveryCodeInvalid,
19 VerificationCodeExpired,
20 VerificationCodeInvalid,
21 ResetTokenInvalid,
1622}
1723
1824impl fmt::Display for AuthError {
@@ -27,6 +33,12 @@
2733 Self::RateLimited => write!(f, "rate limit exceeded"),
2834 Self::PowFailed => write!(f, "proof of work failed"),
2935 Self::Internal(message) => write!(f, "{message}"),
36 Self::TwoFactorExpired => write!(f, "two-factor challenge expired"),
37 Self::TwoFactorCodeInvalid => write!(f, "invalid two-factor code"),
38 Self::RecoveryCodeInvalid => write!(f, "invalid or used recovery code"),
39 Self::VerificationCodeExpired => write!(f, "verification code expired"),
40 Self::VerificationCodeInvalid => write!(f, "invalid verification code"),
41 Self::ResetTokenInvalid => write!(f, "invalid or expired reset token"),
3042 }
3143 }
3244}
@@ -84,6 +96,36 @@
8496 400,
8597 Self::payload(400, "proof of work failed").with_error_code("pow_failed"),
8698 ),
99 AuthError::TwoFactorExpired => Self::build(
100 400,
101 Self::payload(400, "two-factor challenge expired")
102 .with_error_code("two_factor_expired"),
103 ),
104 AuthError::TwoFactorCodeInvalid => Self::build(
105 400,
106 Self::payload(400, "invalid two-factor code")
107 .with_error_code("two_factor_code_invalid"),
108 ),
109 AuthError::RecoveryCodeInvalid => Self::build(
110 400,
111 Self::payload(400, "invalid or used recovery code")
112 .with_error_code("recovery_code_invalid"),
113 ),
114 AuthError::VerificationCodeExpired => Self::build(
115 400,
116 Self::payload(400, "verification code expired")
117 .with_error_code("verification_code_expired"),
118 ),
119 AuthError::VerificationCodeInvalid => Self::build(
120 400,
121 Self::payload(400, "invalid verification code")
122 .with_error_code("verification_code_invalid"),
123 ),
124 AuthError::ResetTokenInvalid => Self::build(
125 400,
126 Self::payload(400, "invalid or expired reset token")
127 .with_error_code("reset_token_invalid"),
128 ),
87129 AuthError::Internal(message) => Self::internal(message),
88130 }
89131 }
Modified lib/axum-session/src/headers.rs lib/axum-session/src/headers.rs +25 −7 View lib/axum-session/src/headers.rs
@@ -180,7 +180,12 @@
180180}
181181
182182#[cfg(not(feature = "rest_mode"))]
183fn create_cookie<'a>(config: &SessionConfig, value: String, cookie_type: NameType) -> Cookie<'a> {
183fn create_cookie<'a>(
184 config: &SessionConfig,
185 value: String,
186 cookie_type: NameType,
187 longterm: bool,
188) -> Cookie<'a> {
184189 let mut cookie_builder = Cookie::build((cookie_type.get_name(config), value))
185190 .path(config.cookie_and_header.cookie_path.clone())
186191 .secure(config.cookie_and_header.cookie_secure)
@@ -191,10 +196,12 @@
191196 cookie_builder = cookie_builder.domain(domain.clone());
192197 }
193198
194 if let Some(max_age) = config.cookie_and_header.cookie_max_age {
195 let time_duration = max_age.to_std().expect("Max Age out of bounds");
196 cookie_builder =
197 cookie_builder.expires(Some((std::time::SystemTime::now() + time_duration).into()));
199 if longterm {
200 if let Some(max_age) = config.cookie_and_header.cookie_max_age {
201 let time_duration = max_age.to_std().expect("Max Age out of bounds");
202 cookie_builder =
203 cookie_builder.expires(Some((std::time::SystemTime::now() + time_duration).into()));
204 }
198205 }
199206
200207 cookie_builder.build()
@@ -277,6 +284,7 @@
277284 ip_user_agent: &str,
278285 destroy: bool,
279286 storable: bool,
287 longterm: bool,
280288) where
281289 T: DatabasePool + Clone + Debug + Sync + Send + 'static,
282290{
@@ -289,7 +297,12 @@
289297 if (storable || !session.store.config.session_mode.is_opt_in()) && !destroy {
290298 cookies
291299 .add_cookie(
292 create_cookie(&session.store.config, session.id.clone(), NameType::Data),
300 create_cookie(
301 &session.store.config,
302 session.id.clone(),
303 NameType::Data,
304 longterm,
305 ),
293306 &session.store.config.cookie_and_header.key,
294307 ip_user_agent.to_owned(),
295308 false,
@@ -310,7 +323,12 @@
310323 if session.store.config.session_mode.is_opt_in() && storable && !destroy {
311324 cookies
312325 .add_cookie(
313 create_cookie(&session.store.config, storable.to_string(), NameType::Store),
326 create_cookie(
327 &session.store.config,
328 storable.to_string(),
329 NameType::Store,
330 longterm,
331 ),
314332 &session.store.config.cookie_and_header.key,
315333 ip_user_agent.to_owned(),
316334 true,
Modified lib/axum-session/src/service.rs lib/axum-session/src/service.rs +5 −0 View lib/axum-session/src/service.rs
@@ -136,6 +136,10 @@
136136 req.extensions_mut().insert(session.clone());
137137
138138 let mut response = ready_inner.call(req).await?;
139 // Read before the response phase purges memory: with
140 // memory_lifespan = 0 the row is evicted below, and the cookie
141 // still has to know whether this session is long-term.
142 let longterm = session.store.is_longterm(&session.id);
139143
140144 // Lets get the last sweep times so we can use them to deturmine if its time to just save sessions.
141145 let (last_sweep, last_database_sweep) = {
@@ -326,6 +330,7 @@
326330 &ip_user_agent,
327331 destroy,
328332 storable,
333 longterm,
329334 )
330335 .await;
331336
Modified lib/axum-session/src/session_store.rs lib/axum-session/src/session_store.rs +5 −0 View lib/axum-session/src/session_store.rs
@@ -392,6 +392,11 @@
392392 }
393393
394394 #[inline]
395 pub(crate) fn is_longterm(&self, id: &str) -> bool {
396 self.inner.get(id).is_some_and(|d| d.longterm)
397 }
398
399 #[inline]
395400 pub(crate) fn set_longterm(&self, id: String, longterm: bool) {
396401 if let Some(mut instance) = self.inner.get_mut(&id) {
397402 instance.set_longterm(longterm);
Added lib/space/src/handlers/get_space_image_file.rs lib/space/src/handlers/get_space_image_file.rs +36 −0 View lib/space/src/handlers/get_space_image_file.rs
@@ -0,0 +1,36 @@
1use axum::extract::{Path, State};
2use axum::http::{StatusCode, header};
3use axum::response::Response;
4use session::{Session, SessionUser};
5
6use crate::errors::ApiError;
7use crate::services::images::SpaceImageKindPath;
8use crate::services::service::SpaceService;
9
10#[utoipa::path(
11 get,
12 path = "/spaces/{space_name}/images/{kind}/file",
13 tag = "space",
14 description = "重定向到 S3 预签名空间图片链接(7 天有效, Redis 缓存)。私有社区对非成员返回 404。",
15 params(SpaceImageKindPath),
16 responses(
17 (status = 302, description = "redirect to presigned s3 space image url"),
18 (status = 404, description = "space or image not found", body = export::rest::AppResponseData<export::rest::NoData>),
19 (status = 401, description = "authentication required", body = export::rest::AppResponseData<export::rest::NoData>)
20 )
21)]
22pub async fn get_space_image_file(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<SpaceImageKindPath>,
26) -> Result<Response, ApiError> {
27 let url = service
28 .space_image_file_url(session.uid(), &path.space_name, path.kind)
29 .await?;
30 Ok(Response::builder()
31 .status(StatusCode::FOUND)
32 .header(header::LOCATION, url)
33 .header(header::CACHE_CONTROL, "private, max-age=300")
34 .body(axum::body::Body::empty())
35 .unwrap())
36}
Modified lib/space/src/handlers/mod.rs lib/space/src/handlers/mod.rs +1 −0 View lib/space/src/handlers/mod.rs
@@ -19,6 +19,7 @@
1919pub mod get_space;
2020pub mod get_space_dir_tree;
2121pub mod get_space_image;
22pub mod get_space_image_file;
2223pub mod join_group_self;
2324pub mod leave_group_self;
2425pub mod list_bans;
Modified lib/space/src/handlers/upload_space_image.rs lib/space/src/handlers/upload_space_image.rs +28 −7 View lib/space/src/handlers/upload_space_image.rs
@@ -1,9 +1,12 @@
1use axum::extract::{Json, Path, State};
1use axum::body::Bytes;
2use axum::extract::{Path, Query, State};
3use axum::http::HeaderMap;
4use axum::http::header::CONTENT_TYPE;
25use export::rest::{AppResponseData, NoData};
36use session::Session;
47
58use crate::errors::ApiError;
6use crate::services::images::{SpaceImageDto, SpaceImageKindPath, UploadSpaceImageCmd};
9use crate::services::images::{SpaceImageDto, SpaceImageKindPath, SpaceImageUploadQuery};
710use crate::services::service::SpaceService;
811use session::share::session::require_uid;
912
@@ -11,9 +14,13 @@
1114 put,
1215 path = "/spaces/{space_name}/images/{kind}",
1316 tag = "space",
14 description = "上传空间头像、横幅、邀请图或嵌入图并更新空间元数据。",
15 params(SpaceImageKindPath),
16 request_body = UploadSpaceImageCmd,
17 description = "上传原始图片字节作为空间头像、横幅、邀请图或嵌入图并更新空间元数据。",
18 params(SpaceImageKindPath, SpaceImageUploadQuery),
19 request_body(
20 content = Vec<u8>,
21 description = "raw image bytes (image/jpeg, image/png, image/webp, image/gif)",
22 content_type = "image/png"
23 ),
1724 responses(
1825 (status = 200, description = "space image uploaded", body = AppResponseData<SpaceImageDto>),
1926 (status = 400, description = "bad request", body = AppResponseData<NoData>),
@@ -26,11 +33,25 @@
2633 State(service): State<SpaceService>,
2734 session: Session,
2835 Path(path): Path<SpaceImageKindPath>,
29 Json(cmd): Json<UploadSpaceImageCmd>,
36 Query(query): Query<SpaceImageUploadQuery>,
37 headers: HeaderMap,
38 body: Bytes,
3039) -> Result<AppResponseData<SpaceImageDto>, ApiError> {
3140 let uid = require_uid(&session)?;
41 let content_type = headers
42 .get(CONTENT_TYPE)
43 .and_then(|v| v.to_str().ok())
44 .unwrap_or("application/octet-stream")
45 .to_string();
3246 let img = service
33 .upload_space_image(uid, &path.space_name, path.kind, cmd)
47 .upload_space_image(
48 uid,
49 &path.space_name,
50 path.kind,
51 &content_type,
52 &body,
53 query,
54 )
3455 .await?;
3556 Ok(AppResponseData::ok(img))
3657}
Deleted lib/space/src/services/avatar.rs lib/space/src/services/avatar.rs +0 −218
@@ -1,218 +0,0 @@
1use base64::Engine;
2use base64::engine::general_purpose::STANDARD;
3use models::space::spaces;
4use sea_orm::{ActiveModelTrait, Set};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::errors::SpaceError;
11use crate::services::service::SpaceService;
12use crate::share::authz::{MANAGE_CHANNELS, effective_permissions, require_perm};
13use crate::share::lookup::lookup_space_by_name;
14
15pub const MAX_SPACE_AVATAR_BYTES: usize = 5 * 1024 * 1024;
16pub const SPACE_AVATAR_PREFIX: &str = "space-avatars";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
19#[serde(rename_all = "lowercase")]
20pub enum AvatarFormat {
21 Jpg,
22 Png,
23 Webp,
24 Gif,
25}
26
27impl AvatarFormat {
28 pub fn extension(&self) -> &'static str {
29 match self {
30 Self::Jpg => "jpg",
31 Self::Png => "png",
32 Self::Webp => "webp",
33 Self::Gif => "gif",
34 }
35 }
36
37 pub fn content_type(&self) -> &'static str {
38 match self {
39 Self::Jpg => "image/jpeg",
40 Self::Png => "image/png",
41 Self::Webp => "image/webp",
42 Self::Gif => "image/gif",
43 }
44 }
45
46 pub fn from_content_type(ct: &str) -> Option<Self> {
47 let clean = ct.trim().to_lowercase();
48 match clean.as_str() {
49 "image/jpeg" | "image/jpg" => Some(Self::Jpg),
50 "image/png" => Some(Self::Png),
51 "image/webp" => Some(Self::Webp),
52 "image/gif" => Some(Self::Gif),
53 _ => None,
54 }
55 }
56
57 pub fn from_extension(ext: &str) -> Option<Self> {
58 let clean = ext.trim().trim_start_matches('.').to_lowercase();
59 match clean.as_str() {
60 "jpg" | "jpeg" => Some(Self::Jpg),
61 "png" => Some(Self::Png),
62 "webp" => Some(Self::Webp),
63 "gif" => Some(Self::Gif),
64 _ => None,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
70pub struct SpaceAvatarDto {
71 pub space_id: Uuid,
72 pub url: String,
73 pub format: AvatarFormat,
74 pub bytes: u64,
75 pub updated_at: OffsetDateTime,
76}
77
78#[derive(Debug, Clone, Deserialize, ToSchema)]
79pub struct UploadSpaceAvatarCmd {
80 pub content_type: String,
81 pub data_base64: String,
82}
83
84impl SpaceService {
85 pub async fn upload_space_avatar(
86 &self,
87 caller: Uuid,
88 space_name: &str,
89 cmd: UploadSpaceAvatarCmd,
90 ) -> Result<SpaceAvatarDto, SpaceError> {
91 let space = lookup_space_by_name(self, space_name).await?;
92 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
93
94 let fmt = AvatarFormat::from_content_type(&cmd.content_type).ok_or_else(|| {
95 SpaceError::BadRequest(format!("unsupported content-type: {}", cmd.content_type))
96 })?;
97
98 let bytes = STANDARD
99 .decode(cmd.data_base64.trim())
100 .map_err(|e| SpaceError::BadRequest(format!("invalid base64: {e}")))?;
101
102 if bytes.is_empty() || bytes.len() > MAX_SPACE_AVATAR_BYTES {
103 return Err(SpaceError::BadRequest(
104 "avatar payload is empty or exceeds 5MB limit".to_string(),
105 ));
106 }
107
108 let space_id = space.id;
109 let key = format!("{SPACE_AVATAR_PREFIX}/{}.{}", space_id, fmt.extension());
110 self.storage()
111 .client()
112 .put_with_content_type(&key, &bytes, fmt.content_type())
113 .await?;
114
115 let url = format!("storage://{key}");
116 let now = OffsetDateTime::now_utc();
117
118 let mut am: spaces::ActiveModel = space.into();
119 am.avatar_url = Set(Some(url.clone()));
120 am.updated_at = Set(now);
121 am.update(self.db().write()).await?;
122
123 let meta_key = format!("{SPACE_AVATAR_PREFIX}/{}.meta.json", space_id);
124 let meta = serde_json::json!({
125 "space_id": space_id,
126 "format": fmt.extension(),
127 "bytes": bytes.len(),
128 "uploaded_at": now,
129 });
130 if let Err(error) = self
131 .storage()
132 .client()
133 .put_with_content_type(&meta_key, meta.to_string().as_bytes(), "application/json")
134 .await
135 {
136 tracing::warn!(%error, "failed to write space avatar meta json");
137 }
138
139 Ok(SpaceAvatarDto {
140 space_id,
141 url,
142 format: fmt,
143 bytes: bytes.len() as u64,
144 updated_at: now,
145 })
146 }
147
148 pub async fn delete_space_avatar(
149 &self,
150 caller: Uuid,
151 space_name: &str,
152 ) -> Result<(), SpaceError> {
153 let space = lookup_space_by_name(self, space_name).await?;
154 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
155
156 let space_id = space.id;
157 let old_url = space.avatar_url.clone();
158 let now = OffsetDateTime::now_utc();
159
160 let mut am: spaces::ActiveModel = space.into();
161 am.avatar_url = Set(None);
162 am.updated_at = Set(now);
163 am.update(self.db().write()).await?;
164
165 if let Some(url) = old_url
166 && let Some(key) = url.strip_prefix("storage://")
167 {
168 if let Err(error) = self.storage().client().delete(key).await {
169 tracing::warn!(%error, "failed to delete old space avatar object");
170 }
171 let meta_key = format!("{SPACE_AVATAR_PREFIX}/{}.meta.json", space_id);
172 if let Err(error) = self.storage().client().delete(&meta_key).await {
173 tracing::warn!(%error, "failed to delete old space avatar meta json");
174 }
175 }
176 Ok(())
177 }
178
179 pub async fn get_space_avatar(
180 &self,
181 caller: Option<Uuid>,
182 space_name: &str,
183 ) -> Result<Option<SpaceAvatarDto>, SpaceError> {
184 let space = lookup_space_by_name(self, space_name).await?;
185 if !space.is_active {
186 return Err(SpaceError::NotFound);
187 }
188
189 if space.is_private {
190 let Some(uid) = caller else {
191 return Err(SpaceError::NotFound);
192 };
193 match effective_permissions(self.db().read(), &space, uid).await {
194 Ok(_) => {}
195 Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound),
196 Err(e) => return Err(e),
197 }
198 }
199
200 let Some(url) = space.avatar_url else {
201 return Ok(None);
202 };
203 if url.is_empty() {
204 return Ok(None);
205 }
206
207 let ext = url.split('.').next_back().unwrap_or("jpg");
208 let format = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
209
210 Ok(Some(SpaceAvatarDto {
211 space_id: space.id,
212 url,
213 format,
214 bytes: 0,
215 updated_at: space.updated_at,
216 }))
217 }
218}
Modified lib/space/src/services/images.rs lib/space/src/services/images.rs +262 −143 View lib/space/src/services/images.rs
@@ -1,5 +1,3 @@
1use base64::Engine;
2use base64::engine::general_purpose::STANDARD;
31use models::space::spaces;
42use sea_orm::{ActiveModelTrait, Set};
53use serde::{Deserialize, Serialize};
@@ -8,13 +6,65 @@
86use uuid::Uuid;
97
108use crate::errors::SpaceError;
11use crate::services::avatar::AvatarFormat;
129use crate::services::service::SpaceService;
1310use crate::share::authz::{MANAGE_CHANNELS, effective_permissions, require_perm};
1411use crate::share::lookup::lookup_space_by_name;
1512
1613pub const MAX_SPACE_IMAGE_BYTES: usize = 5 * 1024 * 1024;
1714pub const SPACE_IMAGE_PREFIX: &str = "space-images";
15pub const SPACE_IMAGE_URL_TTL_SECS: u64 = 7 * 24 * 60 * 60;
16pub const SPACE_IMAGE_URL_CACHE_PREFIX: &str = "space:image:url";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
19#[serde(rename_all = "lowercase")]
20pub enum AvatarFormat {
21 Jpg,
22 Png,
23 Webp,
24 Gif,
25}
26
27impl AvatarFormat {
28 pub fn extension(&self) -> &'static str {
29 match self {
30 Self::Jpg => "jpg",
31 Self::Png => "png",
32 Self::Webp => "webp",
33 Self::Gif => "gif",
34 }
35 }
36
37 pub fn content_type(&self) -> &'static str {
38 match self {
39 Self::Jpg => "image/jpeg",
40 Self::Png => "image/png",
41 Self::Webp => "image/webp",
42 Self::Gif => "image/gif",
43 }
44 }
45
46 pub fn from_content_type(ct: &str) -> Option<Self> {
47 let clean = ct.split(';').next().unwrap_or(ct).trim().to_lowercase();
48 match clean.as_str() {
49 "image/jpeg" | "image/jpg" => Some(Self::Jpg),
50 "image/png" => Some(Self::Png),
51 "image/webp" => Some(Self::Webp),
52 "image/gif" => Some(Self::Gif),
53 _ => None,
54 }
55 }
56
57 pub fn from_extension(ext: &str) -> Option<Self> {
58 let clean = ext.trim().trim_start_matches('.').to_lowercase();
59 match clean.as_str() {
60 "jpg" | "jpeg" => Some(Self::Jpg),
61 "png" => Some(Self::Png),
62 "webp" => Some(Self::Webp),
63 "gif" => Some(Self::Gif),
64 _ => None,
65 }
66 }
67}
1868
1969#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
2070#[serde(rename_all = "snake_case")]
@@ -36,6 +86,61 @@
3686 }
3787}
3888
89fn space_image_url_cache_key(space_id: Uuid, kind: SpaceImageKind) -> String {
90 format!(
91 "{SPACE_IMAGE_URL_CACHE_PREFIX}:{space_id}:{}",
92 kind.as_str()
93 )
94}
95
96fn space_image_object_key(stored_url: &str) -> Option<String> {
97 let key = stored_url.strip_prefix("storage://")?;
98 if key.is_empty() {
99 return None;
100 }
101 Some(key.to_string())
102}
103
104/// Stored locator for the image row of `kind`; `None` when it was never uploaded.
105fn stored_space_image_url(space: &spaces::Model, kind: SpaceImageKind) -> Option<String> {
106 match kind {
107 SpaceImageKind::Avatar => space.avatar_url.clone(),
108 SpaceImageKind::Banner => space.banner_url.clone(),
109 SpaceImageKind::InviteBanner => space.invite_banner_url.clone(),
110 SpaceImageKind::EmbedSplash => space.embed_splash_url.clone(),
111 }
112 .filter(|url| !url.is_empty())
113}
114
115fn write_space_image(
116 am: &mut spaces::ActiveModel,
117 kind: SpaceImageKind,
118 url: Option<String>,
119 width: Option<i32>,
120 height: Option<i32>,
121) {
122 match kind {
123 SpaceImageKind::Avatar => {
124 am.avatar_url = Set(url);
125 }
126 SpaceImageKind::Banner => {
127 am.banner_url = Set(url);
128 am.banner_width = Set(width);
129 am.banner_height = Set(height);
130 }
131 SpaceImageKind::InviteBanner => {
132 am.invite_banner_url = Set(url);
133 am.invite_banner_width = Set(width);
134 am.invite_banner_height = Set(height);
135 }
136 SpaceImageKind::EmbedSplash => {
137 am.embed_splash_url = Set(url);
138 am.embed_splash_width = Set(width);
139 am.embed_splash_height = Set(height);
140 }
141 }
142}
143
39144#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
40145pub struct SpaceImageDto {
41146 pub space_id: Uuid,
@@ -46,16 +151,6 @@
46151 pub height: Option<i32>,
47152 pub bytes: u64,
48153 pub updated_at: OffsetDateTime,
49}
50
51#[derive(Debug, Clone, Deserialize, ToSchema)]
52pub struct UploadSpaceImageCmd {
53 pub content_type: String,
54 pub data_base64: String,
55 #[serde(default)]
56 pub width: Option<i32>,
57 #[serde(default)]
58 pub height: Option<i32>,
59154}
60155
61156#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
@@ -64,97 +159,144 @@
64159 pub kind: SpaceImageKind,
65160}
66161
162/// Banner geometry is recorded, not inferred: the browser knows the intrinsic
163/// size for free, the server would have to decode the image to learn it.
164#[derive(Debug, Clone, Default, Deserialize, IntoParams, ToSchema)]
165pub struct SpaceImageUploadQuery {
166 #[serde(default)]
167 pub width: Option<i32>,
168 #[serde(default)]
169 pub height: Option<i32>,
170}
171
67172impl SpaceService {
173 async fn presign_space_image_url(
174 &self,
175 space_id: Uuid,
176 kind: SpaceImageKind,
177 stored_url: &str,
178 ) -> Result<String, SpaceError> {
179 let cache_key = space_image_url_cache_key(space_id, kind);
180 match export::redis::get_string(&self.state.redis, &cache_key).await {
181 Ok(Some(url)) => return Ok(url),
182 Ok(None) => {}
183 Err(error) => {
184 tracing::warn!(%error, "failed to read space image url cache");
185 }
186 }
187 let key = space_image_object_key(stored_url).ok_or(SpaceError::NotFound)?;
188 let url = self
189 .storage()
190 .client()
191 .presign_get(&key, SPACE_IMAGE_URL_TTL_SECS as u32)?;
192 if let Err(error) = export::redis::set_string(
193 &self.state.redis,
194 &cache_key,
195 &url,
196 SPACE_IMAGE_URL_TTL_SECS,
197 )
198 .await
199 {
200 tracing::warn!(%error, "failed to cache space image presigned url");
201 }
202 Ok(url)
203 }
204
205 /// Member-visible space row for an image read, mirroring `get_space`:
206 /// private spaces are a 404 for strangers rather than a 403, so the image
207 /// URLs leak nothing about which spaces exist.
208 async fn load_space_for_image_read(
209 &self,
210 caller: Option<Uuid>,
211 space_name: &str,
212 ) -> Result<spaces::Model, SpaceError> {
213 let space = lookup_space_by_name(self, space_name).await?;
214 if !space.is_active {
215 return Err(SpaceError::NotFound);
216 }
217 if space.is_private {
218 let Some(uid) = caller else {
219 return Err(SpaceError::NotFound);
220 };
221 match effective_permissions(self.db().read(), &space, uid).await {
222 Ok(_) => {}
223 Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound),
224 Err(e) => return Err(e),
225 }
226 }
227 Ok(space)
228 }
229
68230 pub async fn upload_space_image(
69231 &self,
70232 caller: Uuid,
71233 space_name: &str,
72234 kind: SpaceImageKind,
73 cmd: UploadSpaceImageCmd,
235 content_type: &str,
236 bytes: &[u8],
237 dims: SpaceImageUploadQuery,
74238 ) -> Result<SpaceImageDto, SpaceError> {
239 let SpaceImageUploadQuery { width, height } = dims;
75240 let space = lookup_space_by_name(self, space_name).await?;
76241 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
77242
78 let fmt = AvatarFormat::from_content_type(&cmd.content_type).ok_or_else(|| {
79 SpaceError::BadRequest(format!("unsupported content-type: {}", cmd.content_type))
243 let fmt = AvatarFormat::from_content_type(content_type).ok_or_else(|| {
244 SpaceError::BadRequest(format!("unsupported content-type: {content_type}"))
80245 })?;
81246
82 let bytes = STANDARD
83 .decode(cmd.data_base64.trim())
84 .map_err(|e| SpaceError::BadRequest(format!("invalid base64: {e}")))?;
85
86 if bytes.is_empty() || bytes.len() > MAX_SPACE_IMAGE_BYTES {
87 return Err(SpaceError::BadRequest(
88 "image payload is empty or exceeds 5MB limit".to_string(),
89 ));
247 if bytes.is_empty() {
248 return Err(SpaceError::BadRequest("image payload is empty".to_string()));
90249 }
91
92 if let Some(w) = cmd.width
93 && w < 0
94 {
250 if bytes.len() > MAX_SPACE_IMAGE_BYTES {
95251 return Err(SpaceError::BadRequest(
96 "width must be non-negative".to_string(),
252 "image payload exceeds 5MB limit".to_string(),
97253 ));
98254 }
99 if let Some(h) = cmd.height
100 && h < 0
101 {
255 if width.is_some_and(|w| w < 0) || height.is_some_and(|h| h < 0) {
102256 return Err(SpaceError::BadRequest(
103 "height must be non-negative".to_string(),
257 "image dimensions must be non-negative".to_string(),
104258 ));
105259 }
106260
107261 let space_id = space.id;
108 let key = format!(
109 "{SPACE_IMAGE_PREFIX}/{}/{}.{}",
110 space_id,
262 let lower_name = space.lower_name.clone();
263 let object_key = format!(
264 "{SPACE_IMAGE_PREFIX}/{space_id}/{}.{}",
111265 kind.as_str(),
112266 fmt.extension()
113267 );
268 let stored_url = format!("storage://{object_key}");
269 let now = OffsetDateTime::now_utc();
270
114271 self.storage()
115272 .client()
116 .put_with_content_type(&key, &bytes, fmt.content_type())
273 .put_with_content_type(&object_key, bytes, fmt.content_type())
117274 .await?;
118275
119 let url = format!("storage://{key}");
120 let space_for_url_lower = space.lower_name.clone();
121 let now = OffsetDateTime::now_utc();
122
123276 let mut am: spaces::ActiveModel = space.into();
124 match kind {
125 SpaceImageKind::Avatar => {
126 am.avatar_url = Set(Some(url.clone()));
127 }
128 SpaceImageKind::Banner => {
129 am.banner_url = Set(Some(url.clone()));
130 am.banner_width = Set(cmd.width);
131 am.banner_height = Set(cmd.height);
132 }
133 SpaceImageKind::InviteBanner => {
134 am.invite_banner_url = Set(Some(url.clone()));
135 am.invite_banner_width = Set(cmd.width);
136 am.invite_banner_height = Set(cmd.height);
137 }
138 SpaceImageKind::EmbedSplash => {
139 am.embed_splash_url = Set(Some(url.clone()));
140 am.embed_splash_width = Set(cmd.width);
141 am.embed_splash_height = Set(cmd.height);
142 }
143 }
277 write_space_image(&mut am, kind, Some(stored_url.clone()), width, height);
144278 am.updated_at = Set(now);
145279 am.update(self.db().write()).await?;
146280
281 if let Err(error) = export::redis::delete(
282 &self.state.redis,
283 &space_image_url_cache_key(space_id, kind),
284 )
285 .await
286 {
287 tracing::warn!(%error, "failed to invalidate space image url cache");
288 }
289
147290 let meta_key = format!(
148 "{SPACE_IMAGE_PREFIX}/{}/{}.meta.json",
149 space_id,
291 "{SPACE_IMAGE_PREFIX}/{space_id}/{}.meta.json",
150292 kind.as_str()
151293 );
152294 let meta = serde_json::json!({
153295 "space_id": space_id,
154296 "kind": kind.as_str(),
155297 "format": fmt.extension(),
156 "width": cmd.width,
157 "height": cmd.height,
298 "width": width,
299 "height": height,
158300 "bytes": bytes.len(),
159301 "uploaded_at": now,
160302 });
@@ -171,14 +313,14 @@
171313 space_id,
172314 kind,
173315 url: crate::services::spaces::resolve_space_image_url(
174 &space_for_url_lower,
316 &lower_name,
175317 kind.as_str(),
176 Some(url),
318 Some(stored_url),
177319 )
178320 .expect("resolved"),
179321 format: fmt,
180 width: cmd.width,
181 height: cmd.height,
322 width,
323 height,
182324 bytes: bytes.len() as u64,
183325 updated_at: now,
184326 })
@@ -194,47 +336,29 @@
194336 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
195337
196338 let space_id = space.id;
197 let old_url = match kind {
198 SpaceImageKind::Avatar => space.avatar_url.clone(),
199 SpaceImageKind::Banner => space.banner_url.clone(),
200 SpaceImageKind::InviteBanner => space.invite_banner_url.clone(),
201 SpaceImageKind::EmbedSplash => space.embed_splash_url.clone(),
202 };
339 let old_url = stored_space_image_url(&space, kind);
203340
204341 let now = OffsetDateTime::now_utc();
205342 let mut am: spaces::ActiveModel = space.into();
206 match kind {
207 SpaceImageKind::Avatar => {
208 am.avatar_url = Set(None);
209 }
210 SpaceImageKind::Banner => {
211 am.banner_url = Set(None);
212 am.banner_width = Set(None);
213 am.banner_height = Set(None);
214 }
215 SpaceImageKind::InviteBanner => {
216 am.invite_banner_url = Set(None);
217 am.invite_banner_width = Set(None);
218 am.invite_banner_height = Set(None);
219 }
220 SpaceImageKind::EmbedSplash => {
221 am.embed_splash_url = Set(None);
222 am.embed_splash_width = Set(None);
223 am.embed_splash_height = Set(None);
224 }
225 }
343 write_space_image(&mut am, kind, None, None, None);
226344 am.updated_at = Set(now);
227345 am.update(self.db().write()).await?;
228346
229 if let Some(url) = old_url
230 && let Some(key) = url.strip_prefix("storage://")
347 if let Err(error) = export::redis::delete(
348 &self.state.redis,
349 &space_image_url_cache_key(space_id, kind),
350 )
351 .await
231352 {
232 if let Err(error) = self.storage().client().delete(key).await {
353 tracing::warn!(%error, "failed to invalidate space image url cache");
354 }
355
356 if let Some(key) = old_url.as_deref().and_then(space_image_object_key) {
357 if let Err(error) = self.storage().client().delete(&key).await {
233358 tracing::warn!(%error, "failed to delete old space image object");
234359 }
235360 let meta_key = format!(
236 "{SPACE_IMAGE_PREFIX}/{}/{}.meta.json",
237 space_id,
361 "{SPACE_IMAGE_PREFIX}/{space_id}/{}.meta.json",
238362 kind.as_str()
239363 );
240364 if let Err(error) = self.storage().client().delete(&meta_key).await {
@@ -250,62 +374,57 @@
250374 space_name: &str,
251375 kind: SpaceImageKind,
252376 ) -> Result<Option<SpaceImageDto>, SpaceError> {
253 let space = lookup_space_by_name(self, space_name).await?;
254 if !space.is_active {
255 return Err(SpaceError::NotFound);
256 }
257
258 if space.is_private {
259 let Some(uid) = caller else {
260 return Err(SpaceError::NotFound);
261 };
262 match effective_permissions(self.db().read(), &space, uid).await {
263 Ok(_) => {}
264 Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound),
265 Err(e) => return Err(e),
266 }
267 }
268
269 let (url_opt, width, height) = match kind {
270 SpaceImageKind::Avatar => (space.avatar_url.clone(), None, None),
271 SpaceImageKind::Banner => (space.banner_url, space.banner_width, space.banner_height),
272 SpaceImageKind::InviteBanner => (
273 space.invite_banner_url,
274 space.invite_banner_width,
275 space.invite_banner_height,
276 ),
277 SpaceImageKind::EmbedSplash => (
278 space.embed_splash_url,
279 space.embed_splash_width,
280 space.embed_splash_height,
281 ),
282 };
377 let space = self.load_space_for_image_read(caller, space_name).await?;
283378
284 let Some(url) = url_opt else {
379 let Some(url) = stored_space_image_url(&space, kind) else {
285380 return Ok(None);
286381 };
287 if url.is_empty() {
288 return Ok(None);
289 }
382 let (width, height) = match kind {
383 SpaceImageKind::Avatar => (None, None),
384 SpaceImageKind::Banner => (space.banner_width, space.banner_height),
385 SpaceImageKind::InviteBanner => (space.invite_banner_width, space.invite_banner_height),
386 SpaceImageKind::EmbedSplash => (space.embed_splash_width, space.embed_splash_height),
387 };
290388
291 let ext = url.split('.').next_back().unwrap_or("jpg");
389 let ext = url.rsplit('.').next().unwrap_or("jpg");
292390 let format = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
391 let mut bytes = 0u64;
392 if let Some(key) = space_image_object_key(&url) {
393 match self.storage().client().head(&key).await {
394 Ok(Some((size, _))) => bytes = size,
395 Ok(None) => {}
396 Err(error) => {
397 tracing::warn!(%error, "failed to head space image object");
398 }
399 }
400 }
293401
294 let url = crate::services::spaces::resolve_space_image_url(
295 &space.lower_name.clone(),
296 kind.as_str(),
297 Some(url),
298 )
299 .expect("resolved");
300402 Ok(Some(SpaceImageDto {
301403 space_id: space.id,
302404 kind,
303 url,
405 url: crate::services::spaces::resolve_space_image_url(
406 &space.lower_name,
407 kind.as_str(),
408 Some(url),
409 )
410 .expect("resolved"),
304411 format,
305412 width,
306413 height,
307 bytes: 0,
414 bytes,
308415 updated_at: space.updated_at,
309416 }))
310417 }
418
419 /// Presigned S3 address behind `GET /spaces/{space}/images/{kind}/file`.
420 pub async fn space_image_file_url(
421 &self,
422 caller: Option<Uuid>,
423 space_name: &str,
424 kind: SpaceImageKind,
425 ) -> Result<String, SpaceError> {
426 let space = self.load_space_for_image_read(caller, space_name).await?;
427 let url = stored_space_image_url(&space, kind).ok_or(SpaceError::NotFound)?;
428 self.presign_space_image_url(space.id, kind, &url).await
429 }
311430}
Modified lib/space/src/services/mod.rs lib/space/src/services/mod.rs +0 −1 View lib/space/src/services/mod.rs
@@ -1,4 +1,3 @@
1pub mod avatar;
21pub mod bans;
32pub mod dir;
43pub mod groups;
Modified lib/space/src/services/spaces.rs lib/space/src/services/spaces.rs +22 −19 View lib/space/src/services/spaces.rs
@@ -54,7 +54,7 @@
5454) -> Option<String> {
5555 raw.map(|u| {
5656 if u.starts_with("storage://") {
57 format!("/api/v1/spaces/{space_lower}/images/{kind}")
57 format!("/api/v1/spaces/{space_lower}/images/{kind}/file")
5858 } else {
5959 u
6060 }
@@ -524,24 +524,27 @@
524524
525525 let items = rows
526526 .into_iter()
527 .map(|s| SpaceDto {
528 id: s.id,
529 lower_name: s.lower_name,
530 name: s.name,
531 description: s.description,
532 owner: owner.clone(),
533 avatar_url: s.avatar_url,
534 is_private: s.is_private,
535 is_active: s.is_active,
536 allow_join: s.allow_join,
537 allow_invite: s.allow_invite,
538 members_count: s.nums_members,
539 welcome_message: s.welcome_message,
540 allow_join_level: s.allow_join_level,
541 member_must_enable_2fa: s.member_must_enable_2fa,
542 welcome_object: s.welcome_object,
543 created_at: s.created_at,
544 updated_at: s.updated_at,
527 .map(|s| {
528 let avatar_url = resolve_space_image_url(&s.lower_name, "avatar", s.avatar_url);
529 SpaceDto {
530 id: s.id,
531 lower_name: s.lower_name,
532 name: s.name,
533 description: s.description,
534 owner: owner.clone(),
535 avatar_url,
536 is_private: s.is_private,
537 is_active: s.is_active,
538 allow_join: s.allow_join,
539 allow_invite: s.allow_invite,
540 members_count: s.nums_members,
541 welcome_message: s.welcome_message,
542 allow_join_level: s.allow_join_level,
543 member_must_enable_2fa: s.member_must_enable_2fa,
544 welcome_object: s.welcome_object,
545 created_at: s.created_at,
546 updated_at: s.updated_at,
547 }
545548 })
546549 .collect();
547550
Modified lib/space/src/openapi.rs lib/space/src/openapi.rs +3 −6 View lib/space/src/openapi.rs
@@ -7,6 +7,7 @@
77 crate::handlers::get_space::get_space,
88 crate::handlers::get_group::get_group,
99 crate::handlers::get_space_image::get_space_image,
10 crate::handlers::get_space_image_file::get_space_image_file,
1011 crate::handlers::create_space::create_space,
1112 crate::handlers::list_spaces::list_spaces,
1213 crate::handlers::update_space::update_space,
@@ -66,8 +67,6 @@
6667 AppResponseData<crate::services::invites::InvitePage>,
6768 AppResponseData<crate::services::bans::BanDto>,
6869 AppResponseData<crate::services::bans::BanPage>,
69 AppResponseData<crate::services::avatar::SpaceAvatarDto>,
70 AppResponseData<Option<crate::services::avatar::SpaceAvatarDto>>,
7170 AppResponseData<crate::services::images::SpaceImageDto>,
7271 AppResponseData<Option<crate::services::images::SpaceImageDto>>,
7372 AppResponseData<crate::services::dir::DirNodeDto>,
@@ -99,11 +98,9 @@
9998 crate::services::bans::BanPage,
10099 crate::services::bans::BanUserCmd,
101100 crate::services::bans::UpdateBanCmd,
102 crate::services::avatar::SpaceAvatarDto,
103 crate::services::avatar::UploadSpaceAvatarCmd,
104 crate::services::avatar::AvatarFormat,
101 crate::services::images::AvatarFormat,
105102 crate::services::images::SpaceImageDto,
106 crate::services::images::UploadSpaceImageCmd,
103 crate::services::images::SpaceImageUploadQuery,
107104 crate::services::images::SpaceImageKind,
108105 crate::services::dir::DirNodeDto,
109106 crate::services::dir::CreateDirCmd,
Modified lib/space/src/routes.rs lib/space/src/routes.rs +11 −2 View lib/space/src/routes.rs
@@ -20,6 +20,7 @@
2020use crate::handlers::get_space::get_space;
2121use crate::handlers::get_space_dir_tree::get_space_dir_tree;
2222use crate::handlers::get_space_image::get_space_image;
23use crate::handlers::get_space_image_file::get_space_image_file;
2324use crate::handlers::join_group_self::join_group_self;
2425use crate::handlers::leave_group_self::leave_group_self;
2526use crate::handlers::list_bans::list_bans;
@@ -55,7 +56,11 @@
5556 let public: Router<SpaceService> = Router::new()
5657 .route("/spaces/{space_name}", get(get_space))
5758 .route("/spaces/{space_name}/groups/{group_id}", get(get_group))
58 .route("/spaces/{space_name}/images/{kind}", get(get_space_image));
59 .route("/spaces/{space_name}/images/{kind}", get(get_space_image))
60 .route(
61 "/spaces/{space_name}/images/{kind}/file",
62 get(get_space_image_file),
63 );
5964
6065 let protected = Router::new()
6166 .route("/spaces", post(create_space).get(list_spaces))
@@ -67,7 +72,11 @@
6772 )
6873 .route(
6974 "/spaces/{space_name}/images/{kind}",
70 put(upload_space_image).delete(delete_space_image),
75 put(upload_space_image).delete(delete_space_image).layer(
76 axum::extract::DefaultBodyLimit::max(
77 crate::services::images::MAX_SPACE_IMAGE_BYTES + 1024 * 1024,
78 ),
79 ),
7180 )
7281 .route(
7382 "/spaces/{space_name}/dir",
Modified lib/space/Cargo.toml lib/space/Cargo.toml +1 −2 View lib/space/Cargo.toml
@@ -20,5 +20,4 @@
2020utoipa = { workspace = true }
2121uuid = { workspace = true }
2222time = { workspace = true }
23base64 = { workspace = true }
24tracing = { workspace = true }
No newline at end of file
23tracing = { workspace = true }
Modified lib/user/src/services/profile.rs lib/user/src/services/profile.rs +6 −1 View lib/user/src/services/profile.rs
@@ -166,11 +166,16 @@
166166 granted
167167 };
168168
169 let avatar_url = space::services::spaces::resolve_space_image_url(
170 &space.lower_name,
171 "avatar",
172 space.avatar_url,
173 );
169174 let space_dto = SpaceDto {
170175 id: space.id,
171176 name: space.name,
172177 owner_uid: space.owner_id,
173 avatar_url: space.avatar_url,
178 avatar_url,
174179 is_private: space.is_private,
175180 members_count: space.nums_members,
176181 created_at: space.created_at,
Modified web/design/auth.pen web/design/auth.pen +89 −887 View web/design/auth.pen
@@ -220,7 +220,7 @@
220220 "fill": "$--foreground",
221221 "textGrowth": "fixed-width",
222222 "width": "fill_container",
223 "content": "Sign in to Kernel",
223 "content": "Sign in to GitDataAI",
224224 "textAlign": "center",
225225 "fontFamily": "$--font-primary",
226226 "fontSize": 23,
@@ -234,7 +234,7 @@
234234 "fill": "$--muted-foreground",
235235 "textGrowth": "fixed-width",
236236 "width": "fill_container",
237 "content": "Authenticate to reach the control plane and your running clusters.",
237 "content": "Repositories, boards and team chat in one place.",
238238 "lineHeight": 1.5,
239239 "textAlign": "center",
240240 "fontFamily": "$--font-secondary",
@@ -342,7 +342,7 @@
342342 "id": "rXkqB",
343343 "name": "Label",
344344 "fill": "$--muted-foreground",
345 "content": "PASSPHRASE",
345 "content": "PASSWORD",
346346 "fontFamily": "$--font-primary",
347347 "fontSize": 10,
348348 "fontWeight": "bold",
@@ -572,42 +572,6 @@
572572 "children": [
573573 {
574574 "type": "frame",
575 "id": "KrEzG",
576 "name": "SSO Button",
577 "width": "fill_container",
578 "height": 44,
579 "fill": "$--white",
580 "cornerRadius": 10,
581 "stroke": "$--border",
582 "strokeWidth": 1,
583 "gap": 8,
584 "justifyContent": "center",
585 "alignItems": "center",
586 "children": [
587 {
588 "type": "icon",
589 "id": "iycH7",
590 "name": "Icon",
591 "width": 15,
592 "height": 15,
593 "icon": "shield",
594 "library": "lucide",
595 "fill": "$--foreground"
596 },
597 {
598 "type": "text",
599 "id": "Qs4QL",
600 "name": "Label",
601 "fill": "$--foreground",
602 "content": "Recovery code",
603 "fontFamily": "$--font-secondary",
604 "fontSize": 13.5,
605 "fontWeight": "normal"
606 }
607 ]
608 },
609 {
610 "type": "frame",
611575 "id": "l8EVai",
612576 "name": "Passkey Button",
613577 "width": "fill_container",
@@ -711,27 +675,6 @@
711675 },
712676 {
713677 "type": "text",
714 "id": "Tery6",
715 "name": "Status",
716 "fill": "$--muted-foreground",
717 "content": "ALL SYSTEMS OPERATIONAL",
718 "fontFamily": "$--font-primary",
719 "fontSize": 11,
720 "fontWeight": "normal",
721 "letterSpacing": 0.5
722 },
723 {
724 "type": "text",
725 "id": "LGILM",
726 "name": "Sep",
727 "fill": "$--border",
728 "content": "·",
729 "fontFamily": "$--font-primary",
730 "fontSize": 11,
731 "fontWeight": "normal"
732 },
733 {
734 "type": "text",
735678 "id": "r6jWwp",
736679 "name": "Region",
737680 "fill": "$--muted-foreground",
@@ -766,58 +709,6 @@
766709 },
767710 {
768711 "type": "frame",
769 "id": "Y3IZVr",
770 "layoutPosition": "absolute",
771 "x": 40,
772 "y": 454,
773 "name": "Edge Label L",
774 "rotation": 90,
775 "width": 114,
776 "height": 16,
777 "gap": 10,
778 "alignItems": "center",
779 "children": [
780 {
781 "type": "text",
782 "id": "DxNIq",
783 "name": "Text",
784 "fill": "$--muted-foreground",
785 "content": "AUTH · v4.2.0",
786 "fontFamily": "$--font-primary",
787 "fontSize": 10,
788 "fontWeight": "normal",
789 "letterSpacing": 1.6
790 }
791 ]
792 },
793 {
794 "type": "frame",
795 "id": "guQoV",
796 "layoutPosition": "absolute",
797 "x": 1250,
798 "y": 454,
799 "name": "Edge Label R",
800 "rotation": 90,
801 "width": 152,
802 "height": 16,
803 "gap": 10,
804 "alignItems": "center",
805 "children": [
806 {
807 "type": "text",
808 "id": "PhpkA",
809 "name": "Text",
810 "fill": "$--muted-foreground",
811 "content": "TLS 1.3 · TOTP-SHA1",
812 "fontFamily": "$--font-primary",
813 "fontSize": 10,
814 "fontWeight": "normal",
815 "letterSpacing": 1.6
816 }
817 ]
818 },
819 {
820 "type": "frame",
821712 "id": "wpJqA",
822713 "layoutPosition": "absolute",
823714 "x": 490,
@@ -834,7 +725,7 @@
834725 "id": "ssXbD",
835726 "name": "Hint Text",
836727 "fill": "$--muted-foreground",
837 "content": "PROOF-OF-WORK SOLVED SILENTLY · 5 ATTEMPTS / 5 MIN",
728 "content": "UP TO 5 ATTEMPTS PER ACCOUNT EVERY 5 MINUTES",
838729 "fontFamily": "$--font-primary",
839730 "fontSize": 10,
840731 "fontWeight": "normal",
@@ -1151,7 +1042,7 @@
11511042 "fill": "#666666",
11521043 "textGrowth": "fixed-width",
11531044 "width": "fill_container",
1154 "content": "Provision team access to the Kernel control plane and running clusters.",
1045 "content": "One account for repositories, boards and team chat.",
11551046 "lineHeight": 1.5,
11561047 "textAlign": "center",
11571048 "fontFamily": "Geist",
@@ -1299,7 +1190,7 @@
12991190 "id": "I7y2eY",
13001191 "name": "Value",
13011192 "fill": "#111111",
1302 "content": "ada.lock@kernel.dev",
1193 "content": "ada.lock@gitdata.ai",
13031194 "fontFamily": "Geist",
13041195 "fontSize": 14.5,
13051196 "fontWeight": "normal"
@@ -1311,7 +1202,7 @@
13111202 {
13121203 "type": "frame",
13131204 "id": "y6N6gq",
1314 "name": "PASSPHRASE Field",
1205 "name": "PASSWORD Field",
13151206 "width": "fill_container",
13161207 "layout": "vertical",
13171208 "gap": 7,
@@ -1416,7 +1307,7 @@
14161307 "id": "tMF6K",
14171308 "name": "Label",
14181309 "fill": "#666666",
1419 "content": "DATE OF BIRTH (OPTIONAL)",
1310 "content": "DATE OF BIRTH",
14201311 "fontFamily": "JetBrains Mono",
14211312 "fontSize": 10,
14221313 "fontWeight": "bold",
@@ -1427,7 +1318,7 @@
14271318 "id": "RjZX5",
14281319 "name": "Age Gate",
14291320 "fill": "#804200",
1430 "content": "18+ REQUIRED",
1321 "content": "13+ REQUIRED",
14311322 "fontFamily": "JetBrains Mono",
14321323 "fontSize": 9.5,
14331324 "fontWeight": "bold",
@@ -1778,27 +1669,6 @@
17781669 },
17791670 {
17801671 "type": "text",
1781 "id": "Y4jxCd",
1782 "name": "Status",
1783 "fill": "$--muted-foreground",
1784 "content": "ALL SYSTEMS OPERATIONAL",
1785 "fontFamily": "$--font-primary",
1786 "fontSize": 11,
1787 "fontWeight": "normal",
1788 "letterSpacing": 0.5
1789 },
1790 {
1791 "type": "text",
1792 "id": "Z9XVFy",
1793 "name": "Sep",
1794 "fill": "$--border",
1795 "content": "·",
1796 "fontFamily": "$--font-primary",
1797 "fontSize": 11,
1798 "fontWeight": "normal"
1799 },
1800 {
1801 "type": "text",
18021672 "id": "R6ov9d",
18031673 "name": "Region",
18041674 "fill": "$--muted-foreground",
@@ -1833,58 +1703,6 @@
18331703 },
18341704 {
18351705 "type": "frame",
1836 "id": "HmU8A",
1837 "layoutPosition": "absolute",
1838 "x": 40,
1839 "y": 454,
1840 "name": "Edge Label L",
1841 "rotation": 90,
1842 "width": 114,
1843 "height": 16,
1844 "gap": 10,
1845 "alignItems": "center",
1846 "children": [
1847 {
1848 "type": "text",
1849 "id": "R0yiMf",
1850 "name": "Text",
1851 "fill": "$--muted-foreground",
1852 "content": "AUTH · v4.2.0",
1853 "fontFamily": "$--font-primary",
1854 "fontSize": 10,
1855 "fontWeight": "normal",
1856 "letterSpacing": 1.6
1857 }
1858 ]
1859 },
1860 {
1861 "type": "frame",
1862 "id": "mKEdj",
1863 "layoutPosition": "absolute",
1864 "x": 1250,
1865 "y": 454,
1866 "name": "Edge Label R",
1867 "rotation": 90,
1868 "width": 152,
1869 "height": 16,
1870 "gap": 10,
1871 "alignItems": "center",
1872 "children": [
1873 {
1874 "type": "text",
1875 "id": "veHmT",
1876 "name": "Text",
1877 "fill": "$--muted-foreground",
1878 "content": "TLS 1.3 · TOTP-SHA1",
1879 "fontFamily": "$--font-primary",
1880 "fontSize": 10,
1881 "fontWeight": "normal",
1882 "letterSpacing": 1.6
1883 }
1884 ]
1885 },
1886 {
1887 "type": "frame",
18881706 "id": "mbhxD",
18891707 "layoutPosition": "absolute",
18901708 "x": 490,
@@ -1901,7 +1719,7 @@
19011719 "id": "zy1Z8",
19021720 "name": "Hint Text",
19031721 "fill": "$--muted-foreground",
1904 "content": "PASSWORD POLICY & AGE GATE ENFORCED SERVER-SIDE",
1722 "content": "13+ REQUIRED · PASSWORD AT LEAST 8 CHARACTERS",
19051723 "fontFamily": "$--font-primary",
19061724 "fontSize": 10,
19071725 "fontWeight": "normal",
@@ -2206,7 +2024,7 @@
22062024 "fill": "#111111",
22072025 "textGrowth": "fixed-width",
22082026 "width": "fill_container",
2209 "content": "Forgot passphrase?",
2027 "content": "Forgot password?",
22102028 "textAlign": "center",
22112029 "fontFamily": "JetBrains Mono",
22122030 "fontSize": 23,
@@ -2220,7 +2038,7 @@
22202038 "fill": "#666666",
22212039 "textGrowth": "fixed-width",
22222040 "width": "fill_container",
2223 "content": "Enter your workspace email and we will send a single-use recovery link. It expires after 15 minutes.",
2041 "content": "Enter your account email and we will send a single-use reset link. It expires after 30 minutes.",
22242042 "lineHeight": 1.5,
22252043 "textAlign": "center",
22262044 "fontFamily": "Geist",
@@ -2298,7 +2116,7 @@
22982116 "id": "n9ojj",
22992117 "name": "Value",
23002118 "fill": "#111111",
2301 "content": "ada.lock@kernel.dev",
2119 "content": "ada.lock@gitdata.ai",
23022120 "fontFamily": "Geist",
23032121 "fontSize": 14.5,
23042122 "fontWeight": "normal"
@@ -2340,7 +2158,7 @@
23402158 "fill": "#666666",
23412159 "textGrowth": "fixed-width",
23422160 "width": "fill_container",
2343 "content": "We always confirm delivery — existing accounts are never revealed.",
2161 "content": "If the address is registered you will receive a reset email. For security we never reveal whether an account exists.",
23442162 "lineHeight": 1.45,
23452163 "fontFamily": "Geist",
23462164 "fontSize": 12.5,
@@ -2376,7 +2194,7 @@
23762194 "id": "dGSP8",
23772195 "name": "Label",
23782196 "fill": "#111111",
2379 "content": "Send recovery link",
2197 "content": "Send reset link",
23802198 "fontFamily": "JetBrains Mono",
23812199 "fontSize": 14,
23822200 "fontWeight": "bold",
@@ -2461,27 +2279,6 @@
24612279 },
24622280 {
24632281 "type": "text",
2464 "id": "gkHPL",
2465 "name": "Status",
2466 "fill": "$--muted-foreground",
2467 "content": "ALL SYSTEMS OPERATIONAL",
2468 "fontFamily": "$--font-primary",
2469 "fontSize": 11,
2470 "fontWeight": "normal",
2471 "letterSpacing": 0.5
2472 },
2473 {
2474 "type": "text",
2475 "id": "yZdHF",
2476 "name": "Sep",
2477 "fill": "$--border",
2478 "content": "·",
2479 "fontFamily": "$--font-primary",
2480 "fontSize": 11,
2481 "fontWeight": "normal"
2482 },
2483 {
2484 "type": "text",
24852282 "id": "OA7BA",
24862283 "name": "Region",
24872284 "fill": "$--muted-foreground",
@@ -2516,58 +2313,6 @@
25162313 },
25172314 {
25182315 "type": "frame",
2519 "id": "xDvX0",
2520 "layoutPosition": "absolute",
2521 "x": 40,
2522 "y": 454,
2523 "name": "Edge Label L",
2524 "rotation": 90,
2525 "width": 114,
2526 "height": 16,
2527 "gap": 10,
2528 "alignItems": "center",
2529 "children": [
2530 {
2531 "type": "text",
2532 "id": "wA6To",
2533 "name": "Text",
2534 "fill": "$--muted-foreground",
2535 "content": "AUTH · v4.2.0",
2536 "fontFamily": "$--font-primary",
2537 "fontSize": 10,
2538 "fontWeight": "normal",
2539 "letterSpacing": 1.6
2540 }
2541 ]
2542 },
2543 {
2544 "type": "frame",
2545 "id": "wwDz5",
2546 "layoutPosition": "absolute",
2547 "x": 1250,
2548 "y": 454,
2549 "name": "Edge Label R",
2550 "rotation": 90,
2551 "width": 152,
2552 "height": 16,
2553 "gap": 10,
2554 "alignItems": "center",
2555 "children": [
2556 {
2557 "type": "text",
2558 "id": "GFmhg",
2559 "name": "Text",
2560 "fill": "$--muted-foreground",
2561 "content": "TLS 1.3 · TOTP-SHA1",
2562 "fontFamily": "$--font-primary",
2563 "fontSize": 10,
2564 "fontWeight": "normal",
2565 "letterSpacing": 1.6
2566 }
2567 ]
2568 },
2569 {
2570 "type": "frame",
25712316 "id": "KQjcG",
25722317 "layoutPosition": "absolute",
25732318 "x": 490,
@@ -2584,7 +2329,7 @@
25842329 "id": "teOH6",
25852330 "name": "Hint Text",
25862331 "fill": "$--muted-foreground",
2587 "content": "RECOVERY LINKS EXPIRE AFTER 15 MINUTES",
2332 "content": "RESET LINKS EXPIRE AFTER 30 MINUTES",
25882333 "fontFamily": "$--font-primary",
25892334 "fontSize": 10,
25902335 "fontWeight": "normal",
@@ -2903,7 +2648,7 @@
29032648 "fill": "#666666",
29042649 "textGrowth": "fixed-width",
29052650 "width": "fill_container",
2906 "content": "Recovery token verified. Your new password is checked against the server-side policy.",
2651 "content": "Link verified. Choose your new password.",
29072652 "lineHeight": 1.5,
29082653 "textAlign": "center",
29092654 "fontFamily": "Geist",
@@ -2957,7 +2702,7 @@
29572702 "id": "XQiG8",
29582703 "name": "Email",
29592704 "fill": "#111111",
2960 "content": "ada.lock@kernel.dev",
2705 "content": "ada.lock@gitdata.ai",
29612706 "fontFamily": "JetBrains Mono",
29622707 "fontSize": 12.5,
29632708 "fontWeight": "normal"
@@ -2990,7 +2735,7 @@
29902735 {
29912736 "type": "frame",
29922737 "id": "JD1y8",
2993 "name": "NEW PASSPHRASE Field",
2738 "name": "NEW PASSWORD Field",
29942739 "width": "fill_container",
29952740 "layout": "vertical",
29962741 "gap": 7,
@@ -3143,7 +2888,7 @@
31432888 {
31442889 "type": "frame",
31452890 "id": "G658PO",
3146 "name": "CONFIRM PASSPHRASE Field",
2891 "name": "CONFIRM PASSWORD Field",
31472892 "width": "fill_container",
31482893 "layout": "vertical",
31492894 "gap": 7,
@@ -3255,7 +3000,7 @@
32553000 "id": "W9TT10",
32563001 "name": "Label",
32573002 "fill": "#111111",
3258 "content": "Update passphrase",
3003 "content": "Update password",
32593004 "fontFamily": "JetBrains Mono",
32603005 "fontSize": 14,
32613006 "fontWeight": "bold",
@@ -3310,27 +3055,6 @@
33103055 },
33113056 {
33123057 "type": "text",
3313 "id": "z1JFHU",
3314 "name": "Status",
3315 "fill": "$--muted-foreground",
3316 "content": "ALL SYSTEMS OPERATIONAL",
3317 "fontFamily": "$--font-primary",
3318 "fontSize": 11,
3319 "fontWeight": "normal",
3320 "letterSpacing": 0.5
3321 },
3322 {
3323 "type": "text",
3324 "id": "Mz92z",
3325 "name": "Sep",
3326 "fill": "$--border",
3327 "content": "·",
3328 "fontFamily": "$--font-primary",
3329 "fontSize": 11,
3330 "fontWeight": "normal"
3331 },
3332 {
3333 "type": "text",
33343058 "id": "Sfqx7",
33353059 "name": "Region",
33363060 "fill": "$--muted-foreground",
@@ -3365,58 +3089,6 @@
33653089 },
33663090 {
33673091 "type": "frame",
3368 "id": "cm9ZM",
3369 "layoutPosition": "absolute",
3370 "x": 40,
3371 "y": 454,
3372 "name": "Edge Label L",
3373 "rotation": 90,
3374 "width": 114,
3375 "height": 16,
3376 "gap": 10,
3377 "alignItems": "center",
3378 "children": [
3379 {
3380 "type": "text",
3381 "id": "k5Wmt",
3382 "name": "Text",
3383 "fill": "$--muted-foreground",
3384 "content": "AUTH · v4.2.0",
3385 "fontFamily": "$--font-primary",
3386 "fontSize": 10,
3387 "fontWeight": "normal",
3388 "letterSpacing": 1.6
3389 }
3390 ]
3391 },
3392 {
3393 "type": "frame",
3394 "id": "K4UXle",
3395 "layoutPosition": "absolute",
3396 "x": 1250,
3397 "y": 454,
3398 "name": "Edge Label R",
3399 "rotation": 90,
3400 "width": 152,
3401 "height": 16,
3402 "gap": 10,
3403 "alignItems": "center",
3404 "children": [
3405 {
3406 "type": "text",
3407 "id": "x3ay7n",
3408 "name": "Text",
3409 "fill": "$--muted-foreground",
3410 "content": "TLS 1.3 · TOTP-SHA1",
3411 "fontFamily": "$--font-primary",
3412 "fontSize": 10,
3413 "fontWeight": "normal",
3414 "letterSpacing": 1.6
3415 }
3416 ]
3417 },
3418 {
3419 "type": "frame",
34203092 "id": "uQlOF",
34213093 "layoutPosition": "absolute",
34223094 "x": 490,
@@ -3433,7 +3105,7 @@
34333105 "id": "yD7cw",
34343106 "name": "Hint Text",
34353107 "fill": "$--muted-foreground",
3436 "content": "ALL OTHER SESSIONS WILL BE SIGNED OUT",
3108 "content": "EVERY SIGNED-IN DEVICE MUST SIGN IN AGAIN",
34373109 "fontFamily": "$--font-primary",
34383110 "fontSize": 10,
34393111 "fontWeight": "normal",
@@ -3795,7 +3467,7 @@
37953467 "id": "UOq7k",
37963468 "name": "Email",
37973469 "fill": "#111111",
3798 "content": "ada.lock@kernel.dev",
3470 "content": "ada.lock@gitdata.ai",
37993471 "fontFamily": "JetBrains Mono",
38003472 "fontSize": 12.5,
38013473 "fontWeight": "normal"
@@ -3820,7 +3492,7 @@
38203492 "id": "RQzQR",
38213493 "name": "Text",
38223494 "fill": "#666666",
3823 "content": "PURPOSE · SIGNUP",
3495 "content": "PURPOSE · SIGN-UP",
38243496 "fontFamily": "JetBrains Mono",
38253497 "fontSize": 9.5,
38263498 "fontWeight": "bold",
@@ -3837,7 +3509,7 @@
38373509 "fill": "#666666",
38383510 "textGrowth": "fixed-width",
38393511 "width": "fill_container",
3840 "content": "The link expires in 24 hours. Open it on this device to continue.",
3512 "content": "The code and link in the email expire in 10 minutes.",
38413513 "lineHeight": 1.5,
38423514 "textAlign": "center",
38433515 "fontFamily": "Geist",
@@ -3882,7 +3554,7 @@
38823554 "id": "Ftqt6",
38833555 "name": "Label",
38843556 "fill": "#111111",
3885 "content": "I have verified my email",
3557 "content": "Confirm verification",
38863558 "fontFamily": "JetBrains Mono",
38873559 "fontSize": 14,
38883560 "fontWeight": "bold",
@@ -4040,27 +3712,6 @@
40403712 },
40413713 {
40423714 "type": "text",
4043 "id": "ap4XC",
4044 "name": "Status",
4045 "fill": "$--muted-foreground",
4046 "content": "ALL SYSTEMS OPERATIONAL",
4047 "fontFamily": "$--font-primary",
4048 "fontSize": 11,
4049 "fontWeight": "normal",
4050 "letterSpacing": 0.5
4051 },
4052 {
4053 "type": "text",
4054 "id": "tgUTa",
4055 "name": "Sep",
4056 "fill": "$--border",
4057 "content": "·",
4058 "fontFamily": "$--font-primary",
4059 "fontSize": 11,
4060 "fontWeight": "normal"
4061 },
4062 {
4063 "type": "text",
40643715 "id": "UVcSv",
40653716 "name": "Region",
40663717 "fill": "$--muted-foreground",
@@ -4095,58 +3746,6 @@
40953746 },
40963747 {
40973748 "type": "frame",
4098 "id": "mzoZK",
4099 "layoutPosition": "absolute",
4100 "x": 40,
4101 "y": 454,
4102 "name": "Edge Label L",
4103 "rotation": 90,
4104 "width": 114,
4105 "height": 16,
4106 "gap": 10,
4107 "alignItems": "center",
4108 "children": [
4109 {
4110 "type": "text",
4111 "id": "puM7q",
4112 "name": "Text",
4113 "fill": "$--muted-foreground",
4114 "content": "AUTH · v4.2.0",
4115 "fontFamily": "$--font-primary",
4116 "fontSize": 10,
4117 "fontWeight": "normal",
4118 "letterSpacing": 1.6
4119 }
4120 ]
4121 },
4122 {
4123 "type": "frame",
4124 "id": "YY55d",
4125 "layoutPosition": "absolute",
4126 "x": 1250,
4127 "y": 454,
4128 "name": "Edge Label R",
4129 "rotation": 90,
4130 "width": 152,
4131 "height": 16,
4132 "gap": 10,
4133 "alignItems": "center",
4134 "children": [
4135 {
4136 "type": "text",
4137 "id": "BmmIy",
4138 "name": "Text",
4139 "fill": "$--muted-foreground",
4140 "content": "TLS 1.3 · TOTP-SHA1",
4141 "fontFamily": "$--font-primary",
4142 "fontSize": 10,
4143 "fontWeight": "normal",
4144 "letterSpacing": 1.6
4145 }
4146 ]
4147 },
4148 {
4149 "type": "frame",
41503749 "id": "bXqyI",
41513750 "layoutPosition": "absolute",
41523751 "x": 490,
@@ -4163,7 +3762,7 @@
41633762 "id": "tRTTj",
41643763 "name": "Hint Text",
41653764 "fill": "$--muted-foreground",
4166 "content": "CHECK SPAM FOLDER · SMTP DELIVERY ~5S",
3765 "content": "NO EMAIL YET? CHECK YOUR SPAM FOLDER",
41673766 "fontFamily": "$--font-primary",
41683767 "fontSize": 10,
41693768 "fontWeight": "normal",
@@ -4480,7 +4079,7 @@
44804079 "fill": "#666666",
44814080 "textGrowth": "fixed-width",
44824081 "width": "fill_container",
4483 "content": "Enter the 6-digit code from your authenticator app for account ada.lock@kernel.dev.",
4082 "content": "Enter the 6-digit code from your authenticator app.",
44844083 "lineHeight": 1.5,
44854084 "textAlign": "center",
44864085 "fontFamily": "Geist",
@@ -4837,27 +4436,6 @@
48374436 },
48384437 {
48394438 "type": "text",
4840 "id": "v6qzZz",
4841 "name": "Status",
4842 "fill": "$--muted-foreground",
4843 "content": "ALL SYSTEMS OPERATIONAL",
4844 "fontFamily": "$--font-primary",
4845 "fontSize": 11,
4846 "fontWeight": "normal",
4847 "letterSpacing": 0.5
4848 },
4849 {
4850 "type": "text",
4851 "id": "p5cTB",
4852 "name": "Sep",
4853 "fill": "$--border",
4854 "content": "·",
4855 "fontFamily": "$--font-primary",
4856 "fontSize": 11,
4857 "fontWeight": "normal"
4858 },
4859 {
4860 "type": "text",
48614439 "id": "TLLo6",
48624440 "name": "Region",
48634441 "fill": "$--muted-foreground",
@@ -4886,59 +4464,7 @@
48864464 "fontFamily": "$--font-primary",
48874465 "fontSize": 11,
48884466 "fontWeight": "normal",
4889 "letterSpacing": 0.5
4890 }
4891 ]
4892 },
4893 {
4894 "type": "frame",
4895 "id": "IYmeV",
4896 "layoutPosition": "absolute",
4897 "x": 40,
4898 "y": 454,
4899 "name": "Edge Label L",
4900 "rotation": 90,
4901 "width": 114,
4902 "height": 16,
4903 "gap": 10,
4904 "alignItems": "center",
4905 "children": [
4906 {
4907 "type": "text",
4908 "id": "jFCzW",
4909 "name": "Text",
4910 "fill": "$--muted-foreground",
4911 "content": "AUTH · v4.2.0",
4912 "fontFamily": "$--font-primary",
4913 "fontSize": 10,
4914 "fontWeight": "normal",
4915 "letterSpacing": 1.6
4916 }
4917 ]
4918 },
4919 {
4920 "type": "frame",
4921 "id": "dd6AQ",
4922 "layoutPosition": "absolute",
4923 "x": 1250,
4924 "y": 454,
4925 "name": "Edge Label R",
4926 "rotation": 90,
4927 "width": 152,
4928 "height": 16,
4929 "gap": 10,
4930 "alignItems": "center",
4931 "children": [
4932 {
4933 "type": "text",
4934 "id": "MlITn",
4935 "name": "Text",
4936 "fill": "$--muted-foreground",
4937 "content": "TLS 1.3 · TOTP-SHA1",
4938 "fontFamily": "$--font-primary",
4939 "fontSize": 10,
4940 "fontWeight": "normal",
4941 "letterSpacing": 1.6
4467 "letterSpacing": 0.5
49424468 }
49434469 ]
49444470 },
@@ -4960,7 +4486,7 @@
49604486 "id": "Of7w5",
49614487 "name": "Hint Text",
49624488 "fill": "$--muted-foreground",
4963 "content": "CHALLENGE EXPIRES IN 5 MIN · 5 ATTEMPTS MAX",
4489 "content": "CODE VALID FOR 5 MIN · 5 ATTEMPTS MAX",
49644490 "fontFamily": "$--font-primary",
49654491 "fontSize": 10,
49664492 "fontWeight": "normal",
@@ -5261,7 +4787,7 @@
52614787 "fill": "$--foreground",
52624788 "textGrowth": "fixed-width",
52634789 "width": "fill_container",
5264 "content": "Sign in to Kernel",
4790 "content": "Sign in to GitDataAI",
52654791 "textAlign": "center",
52664792 "fontFamily": "$--font-primary",
52674793 "fontSize": 23,
@@ -5275,7 +4801,7 @@
52754801 "fill": "$--muted-foreground",
52764802 "textGrowth": "fixed-width",
52774803 "width": "fill_container",
5278 "content": "Authenticate to reach the control plane and your running clusters.",
4804 "content": "Repositories, boards and team chat in one place.",
52794805 "lineHeight": 1.5,
52804806 "textAlign": "center",
52814807 "fontFamily": "$--font-secondary",
@@ -5383,7 +4909,7 @@
53834909 "id": "wKnA2",
53844910 "name": "Label",
53854911 "fill": "$--muted-foreground",
5386 "content": "PASSPHRASE",
4912 "content": "PASSWORD",
53874913 "fontFamily": "$--font-primary",
53884914 "fontSize": 10,
53894915 "fontWeight": "bold",
@@ -5613,42 +5139,6 @@
56135139 "children": [
56145140 {
56155141 "type": "frame",
5616 "id": "c7An5t",
5617 "name": "SSO Button",
5618 "width": "fill_container",
5619 "height": 44,
5620 "fill": "$--white",
5621 "cornerRadius": 10,
5622 "stroke": "$--border",
5623 "strokeWidth": 1,
5624 "gap": 8,
5625 "justifyContent": "center",
5626 "alignItems": "center",
5627 "children": [
5628 {
5629 "type": "icon",
5630 "id": "j0cyI0",
5631 "name": "Icon",
5632 "width": 15,
5633 "height": 15,
5634 "icon": "shield",
5635 "library": "lucide",
5636 "fill": "$--foreground"
5637 },
5638 {
5639 "type": "text",
5640 "id": "O85Rc",
5641 "name": "Label",
5642 "fill": "$--foreground",
5643 "content": "Recovery code",
5644 "fontFamily": "$--font-secondary",
5645 "fontSize": 13.5,
5646 "fontWeight": "normal"
5647 }
5648 ]
5649 },
5650 {
5651 "type": "frame",
56525142 "id": "dswH7",
56535143 "name": "Passkey Button",
56545144 "width": "fill_container",
@@ -5752,27 +5242,6 @@
57525242 },
57535243 {
57545244 "type": "text",
5755 "id": "o0lExX",
5756 "name": "Status",
5757 "fill": "$--muted-foreground",
5758 "content": "ALL SYSTEMS OPERATIONAL",
5759 "fontFamily": "$--font-primary",
5760 "fontSize": 11,
5761 "fontWeight": "normal",
5762 "letterSpacing": 0.5
5763 },
5764 {
5765 "type": "text",
5766 "id": "q3fKY",
5767 "name": "Sep",
5768 "fill": "$--border",
5769 "content": "·",
5770 "fontFamily": "$--font-primary",
5771 "fontSize": 11,
5772 "fontWeight": "normal"
5773 },
5774 {
5775 "type": "text",
57765245 "id": "OSft1",
57775246 "name": "Region",
57785247 "fill": "$--muted-foreground",
@@ -5823,7 +5292,7 @@
58235292 "id": "QZoYm",
58245293 "name": "Hint Text",
58255294 "fill": "$--muted-foreground",
5826 "content": "PROOF-OF-WORK SOLVED SILENTLY · 5 ATTEMPTS / 5 MIN",
5295 "content": "UP TO 5 ATTEMPTS PER ACCOUNT EVERY 5 MINUTES",
58275296 "fontFamily": "$--font-primary",
58285297 "fontSize": 10,
58295298 "fontWeight": "normal",
@@ -6126,7 +5595,7 @@
61265595 "fill": "$--foreground",
61275596 "textGrowth": "fixed-width",
61285597 "width": "fill_container",
6129 "content": "Sign in to Kernel",
5598 "content": "Sign in to GitDataAI",
61305599 "textAlign": "center",
61315600 "fontFamily": "$--font-primary",
61325601 "fontSize": 21,
@@ -6140,7 +5609,7 @@
61405609 "fill": "$--muted-foreground",
61415610 "textGrowth": "fixed-width",
61425611 "width": "fill_container",
6143 "content": "Authenticate to reach the control plane and your running clusters.",
5612 "content": "Repositories, boards and team chat in one place.",
61445613 "lineHeight": 1.5,
61455614 "textAlign": "center",
61465615 "fontFamily": "$--font-secondary",
@@ -6248,7 +5717,7 @@
62485717 "id": "TkiRO",
62495718 "name": "Label",
62505719 "fill": "$--muted-foreground",
6251 "content": "PASSPHRASE",
5720 "content": "PASSWORD",
62525721 "fontFamily": "$--font-primary",
62535722 "fontSize": 10,
62545723 "fontWeight": "bold",
@@ -6479,42 +5948,6 @@
64795948 "children": [
64805949 {
64815950 "type": "frame",
6482 "id": "hz3B5",
6483 "name": "SSO Button",
6484 "width": "fill_container",
6485 "height": 44,
6486 "fill": "$--white",
6487 "cornerRadius": 10,
6488 "stroke": "$--border",
6489 "strokeWidth": 1,
6490 "gap": 8,
6491 "justifyContent": "center",
6492 "alignItems": "center",
6493 "children": [
6494 {
6495 "type": "icon",
6496 "id": "f5WjYt",
6497 "name": "Icon",
6498 "width": 15,
6499 "height": 15,
6500 "icon": "shield",
6501 "library": "lucide",
6502 "fill": "$--foreground"
6503 },
6504 {
6505 "type": "text",
6506 "id": "r4KOTy",
6507 "name": "Label",
6508 "fill": "$--foreground",
6509 "content": "Recovery code",
6510 "fontFamily": "$--font-secondary",
6511 "fontSize": 13.5,
6512 "fontWeight": "normal"
6513 }
6514 ]
6515 },
6516 {
6517 "type": "frame",
65185951 "id": "PHA4u",
65195952 "name": "Passkey Button",
65205953 "width": "fill_container",
@@ -6618,27 +6051,6 @@
66186051 },
66196052 {
66206053 "type": "text",
6621 "id": "lmAs4",
6622 "name": "Status",
6623 "fill": "$--muted-foreground",
6624 "content": "ALL SYSTEMS OPERATIONAL",
6625 "fontFamily": "$--font-primary",
6626 "fontSize": 11,
6627 "fontWeight": "normal",
6628 "letterSpacing": 0.5
6629 },
6630 {
6631 "type": "text",
6632 "id": "gM3FI",
6633 "name": "Sep",
6634 "fill": "$--border",
6635 "content": "·",
6636 "fontFamily": "$--font-primary",
6637 "fontSize": 11,
6638 "fontWeight": "normal"
6639 },
6640 {
6641 "type": "text",
66426054 "id": "gkytJ",
66436055 "name": "Copy",
66446056 "fill": "$--muted-foreground",
@@ -6668,7 +6080,7 @@
66686080 "id": "oTwEX",
66696081 "name": "Hint Text",
66706082 "fill": "$--muted-foreground",
6671 "content": "PROOF-OF-WORK SOLVED SILENTLY · 5 ATTEMPTS / 5 MIN",
6083 "content": "UP TO 5 ATTEMPTS PER ACCOUNT EVERY 5 MINUTES",
66726084 "fontFamily": "$--font-primary",
66736085 "fontSize": 10,
66746086 "fontWeight": "normal",
@@ -6909,7 +6321,7 @@
69096321 "fill": "#666666",
69106322 "textGrowth": "fixed-width",
69116323 "width": "fill_container",
6912 "content": "Provision team access to the Kernel control plane and running clusters.",
6324 "content": "One account for repositories, boards and team chat.",
69136325 "lineHeight": 1.5,
69146326 "textAlign": "center",
69156327 "fontFamily": "Geist",
@@ -7057,7 +6469,7 @@
70576469 "id": "qtTsr",
70586470 "name": "Value",
70596471 "fill": "#111111",
7060 "content": "ada.lock@kernel.dev",
6472 "content": "ada.lock@gitdata.ai",
70616473 "fontFamily": "Geist",
70626474 "fontSize": 14.5,
70636475 "fontWeight": "normal"
@@ -7069,7 +6481,7 @@
70696481 {
70706482 "type": "frame",
70716483 "id": "z8mPbq",
7072 "name": "PASSPHRASE Field",
6484 "name": "PASSWORD Field",
70736485 "width": "fill_container",
70746486 "layout": "vertical",
70756487 "gap": 7,
@@ -7174,7 +6586,7 @@
71746586 "id": "wHzPs",
71756587 "name": "Label",
71766588 "fill": "#666666",
7177 "content": "DATE OF BIRTH (OPTIONAL)",
6589 "content": "DATE OF BIRTH",
71786590 "fontFamily": "JetBrains Mono",
71796591 "fontSize": 10,
71806592 "fontWeight": "bold",
@@ -7185,7 +6597,7 @@
71856597 "id": "X7qUZ",
71866598 "name": "Age Gate",
71876599 "fill": "#804200",
7188 "content": "18+ REQUIRED",
6600 "content": "13+ REQUIRED",
71896601 "fontFamily": "JetBrains Mono",
71906602 "fontSize": 9.5,
71916603 "fontWeight": "bold",
@@ -7536,27 +6948,6 @@
75366948 },
75376949 {
75386950 "type": "text",
7539 "id": "OIZCv",
7540 "name": "Status",
7541 "fill": "$--muted-foreground",
7542 "content": "ALL SYSTEMS OPERATIONAL",
7543 "fontFamily": "$--font-primary",
7544 "fontSize": 11,
7545 "fontWeight": "normal",
7546 "letterSpacing": 0.5
7547 },
7548 {
7549 "type": "text",
7550 "id": "V62iUI",
7551 "name": "Sep",
7552 "fill": "$--border",
7553 "content": "·",
7554 "fontFamily": "$--font-primary",
7555 "fontSize": 11,
7556 "fontWeight": "normal"
7557 },
7558 {
7559 "type": "text",
75606951 "id": "hK8E1",
75616952 "name": "Region",
75626953 "fill": "$--muted-foreground",
@@ -7607,7 +6998,7 @@
76076998 "id": "ieUKW",
76086999 "name": "Hint Text",
76097000 "fill": "$--muted-foreground",
7610 "content": "PASSWORD POLICY & AGE GATE ENFORCED SERVER-SIDE",
7001 "content": "13+ REQUIRED · PASSWORD AT LEAST 8 CHARACTERS",
76117002 "fontFamily": "$--font-primary",
76127003 "fontSize": 10,
76137004 "fontWeight": "normal",
@@ -7924,7 +7315,7 @@
79247315 "fill": "#666666",
79257316 "textGrowth": "fixed-width",
79267317 "width": "fill_container",
7927 "content": "Provision team access to the Kernel control plane and running clusters.",
7318 "content": "One account for repositories, boards and team chat.",
79287319 "lineHeight": 1.5,
79297320 "textAlign": "center",
79307321 "fontFamily": "Geist",
@@ -8072,7 +7463,7 @@
80727463 "id": "eH7ir",
80737464 "name": "Value",
80747465 "fill": "#111111",
8075 "content": "ada.lock@kernel.dev",
7466 "content": "ada.lock@gitdata.ai",
80767467 "fontFamily": "Geist",
80777468 "fontSize": 14.5,
80787469 "fontWeight": "normal"
@@ -8084,7 +7475,7 @@
80847475 {
80857476 "type": "frame",
80867477 "id": "C3T7YT",
8087 "name": "PASSPHRASE Field",
7478 "name": "PASSWORD Field",
80887479 "width": "fill_container",
80897480 "layout": "vertical",
80907481 "gap": 7,
@@ -8189,7 +7580,7 @@
81897580 "id": "YnIj3",
81907581 "name": "Label",
81917582 "fill": "#666666",
8192 "content": "DATE OF BIRTH (OPTIONAL)",
7583 "content": "DATE OF BIRTH",
81937584 "fontFamily": "JetBrains Mono",
81947585 "fontSize": 10,
81957586 "fontWeight": "bold",
@@ -8200,7 +7591,7 @@
82007591 "id": "J9pQm",
82017592 "name": "Age Gate",
82027593 "fill": "#804200",
8203 "content": "18+ REQUIRED",
7594 "content": "13+ REQUIRED",
82047595 "fontFamily": "JetBrains Mono",
82057596 "fontSize": 9.5,
82067597 "fontWeight": "bold",
@@ -8551,27 +7942,6 @@
85517942 },
85527943 {
85537944 "type": "text",
8554 "id": "O2iXaE",
8555 "name": "Status",
8556 "fill": "$--muted-foreground",
8557 "content": "ALL SYSTEMS OPERATIONAL",
8558 "fontFamily": "$--font-primary",
8559 "fontSize": 11,
8560 "fontWeight": "normal",
8561 "letterSpacing": 0.5
8562 },
8563 {
8564 "type": "text",
8565 "id": "kkW8E",
8566 "name": "Sep",
8567 "fill": "$--border",
8568 "content": "·",
8569 "fontFamily": "$--font-primary",
8570 "fontSize": 11,
8571 "fontWeight": "normal"
8572 },
8573 {
8574 "type": "text",
85757945 "id": "jX13B",
85767946 "name": "Copy",
85777947 "fill": "$--muted-foreground",
@@ -8601,7 +7971,7 @@
86017971 "id": "uscs9",
86027972 "name": "Hint Text",
86037973 "fill": "$--muted-foreground",
8604 "content": "PASSWORD POLICY & AGE GATE ENFORCED SERVER-SIDE",
7974 "content": "13+ REQUIRED · PASSWORD AT LEAST 8 CHARACTERS",
86057975 "fontFamily": "$--font-primary",
86067976 "fontSize": 10,
86077977 "fontWeight": "normal",
@@ -8830,7 +8200,7 @@
88308200 "fill": "#111111",
88318201 "textGrowth": "fixed-width",
88328202 "width": "fill_container",
8833 "content": "Forgot passphrase?",
8203 "content": "Forgot password?",
88348204 "textAlign": "center",
88358205 "fontFamily": "JetBrains Mono",
88368206 "fontSize": 23,
@@ -8844,7 +8214,7 @@
88448214 "fill": "#666666",
88458215 "textGrowth": "fixed-width",
88468216 "width": "fill_container",
8847 "content": "Enter your workspace email and we will send a single-use recovery link. It expires after 15 minutes.",
8217 "content": "Enter your account email and we will send a single-use reset link. It expires after 30 minutes.",
88488218 "lineHeight": 1.5,
88498219 "textAlign": "center",
88508220 "fontFamily": "Geist",
@@ -8922,7 +8292,7 @@
89228292 "id": "xuvxH",
89238293 "name": "Value",
89248294 "fill": "#111111",
8925 "content": "ada.lock@kernel.dev",
8295 "content": "ada.lock@gitdata.ai",
89268296 "fontFamily": "Geist",
89278297 "fontSize": 14.5,
89288298 "fontWeight": "normal"
@@ -8964,7 +8334,7 @@
89648334 "fill": "#666666",
89658335 "textGrowth": "fixed-width",
89668336 "width": "fill_container",
8967 "content": "We always confirm delivery — existing accounts are never revealed.",
8337 "content": "If the address is registered you will receive a reset email. For security we never reveal whether an account exists.",
89688338 "lineHeight": 1.45,
89698339 "fontFamily": "Geist",
89708340 "fontSize": 12.5,
@@ -9000,7 +8370,7 @@
90008370 "id": "Jxoyh",
90018371 "name": "Label",
90028372 "fill": "#111111",
9003 "content": "Send recovery link",
8373 "content": "Send reset link",
90048374 "fontFamily": "JetBrains Mono",
90058375 "fontSize": 14,
90068376 "fontWeight": "bold",
@@ -9085,27 +8455,6 @@
90858455 },
90868456 {
90878457 "type": "text",
9088 "id": "dGSLz",
9089 "name": "Status",
9090 "fill": "$--muted-foreground",
9091 "content": "ALL SYSTEMS OPERATIONAL",
9092 "fontFamily": "$--font-primary",
9093 "fontSize": 11,
9094 "fontWeight": "normal",
9095 "letterSpacing": 0.5
9096 },
9097 {
9098 "type": "text",
9099 "id": "VZEbc",
9100 "name": "Sep",
9101 "fill": "$--border",
9102 "content": "·",
9103 "fontFamily": "$--font-primary",
9104 "fontSize": 11,
9105 "fontWeight": "normal"
9106 },
9107 {
9108 "type": "text",
91098458 "id": "VEHxx",
91108459 "name": "Region",
91118460 "fill": "$--muted-foreground",
@@ -9156,7 +8505,7 @@
91568505 "id": "w6rJjR",
91578506 "name": "Hint Text",
91588507 "fill": "$--muted-foreground",
9159 "content": "RECOVERY LINKS EXPIRE AFTER 15 MINUTES",
8508 "content": "RESET LINKS EXPIRE AFTER 30 MINUTES",
91608509 "fontFamily": "$--font-primary",
91618510 "fontSize": 10,
91628511 "fontWeight": "normal",
@@ -9461,7 +8810,7 @@
94618810 "fill": "#111111",
94628811 "textGrowth": "fixed-width",
94638812 "width": "fill_container",
9464 "content": "Forgot passphrase?",
8813 "content": "Forgot password?",
94658814 "textAlign": "center",
94668815 "fontFamily": "JetBrains Mono",
94678816 "fontSize": 21,
@@ -9475,7 +8824,7 @@
94758824 "fill": "#666666",
94768825 "textGrowth": "fixed-width",
94778826 "width": "fill_container",
9478 "content": "Enter your workspace email and we will send a single-use recovery link. It expires after 15 minutes.",
8827 "content": "Enter your account email and we will send a single-use reset link. It expires after 30 minutes.",
94798828 "lineHeight": 1.5,
94808829 "textAlign": "center",
94818830 "fontFamily": "Geist",
@@ -9553,7 +8902,7 @@
95538902 "id": "DBONq",
95548903 "name": "Value",
95558904 "fill": "#111111",
9556 "content": "ada.lock@kernel.dev",
8905 "content": "ada.lock@gitdata.ai",
95578906 "fontFamily": "Geist",
95588907 "fontSize": 14.5,
95598908 "fontWeight": "normal"
@@ -9595,7 +8944,7 @@
95958944 "fill": "#666666",
95968945 "textGrowth": "fixed-width",
95978946 "width": "fill_container",
9598 "content": "We always confirm delivery — existing accounts are never revealed.",
8947 "content": "If the address is registered you will receive a reset email. For security we never reveal whether an account exists.",
95998948 "lineHeight": 1.45,
96008949 "fontFamily": "Geist",
96018950 "fontSize": 12.5,
@@ -9631,7 +8980,7 @@
96318980 "id": "MOCPS",
96328981 "name": "Label",
96338982 "fill": "#111111",
9634 "content": "Send recovery link",
8983 "content": "Send reset link",
96358984 "fontFamily": "JetBrains Mono",
96368985 "fontSize": 14,
96378986 "fontWeight": "bold",
@@ -9716,27 +9065,6 @@
97169065 },
97179066 {
97189067 "type": "text",
9719 "id": "i8Jqu",
9720 "name": "Status",
9721 "fill": "$--muted-foreground",
9722 "content": "ALL SYSTEMS OPERATIONAL",
9723 "fontFamily": "$--font-primary",
9724 "fontSize": 11,
9725 "fontWeight": "normal",
9726 "letterSpacing": 0.5
9727 },
9728 {
9729 "type": "text",
9730 "id": "X2HWzo",
9731 "name": "Sep",
9732 "fill": "$--border",
9733 "content": "·",
9734 "fontFamily": "$--font-primary",
9735 "fontSize": 11,
9736 "fontWeight": "normal"
9737 },
9738 {
9739 "type": "text",
97409068 "id": "c9f63f",
97419069 "name": "Copy",
97429070 "fill": "$--muted-foreground",
@@ -9766,7 +9094,7 @@
97669094 "id": "fZfMy",
97679095 "name": "Hint Text",
97689096 "fill": "$--muted-foreground",
9769 "content": "RECOVERY LINKS EXPIRE AFTER 15 MINUTES",
9097 "content": "RESET LINKS EXPIRE AFTER 30 MINUTES",
97709098 "fontFamily": "$--font-primary",
97719099 "fontSize": 10,
97729100 "fontWeight": "normal",
@@ -10009,7 +9337,7 @@
100099337 "fill": "#666666",
100109338 "textGrowth": "fixed-width",
100119339 "width": "fill_container",
10012 "content": "Recovery token verified. Your new password is checked against the server-side policy.",
9340 "content": "Link verified. Choose your new password.",
100139341 "lineHeight": 1.5,
100149342 "textAlign": "center",
100159343 "fontFamily": "Geist",
@@ -10063,7 +9391,7 @@
100639391 "id": "hCisd",
100649392 "name": "Email",
100659393 "fill": "#111111",
10066 "content": "ada.lock@kernel.dev",
9394 "content": "ada.lock@gitdata.ai",
100679395 "fontFamily": "JetBrains Mono",
100689396 "fontSize": 12.5,
100699397 "fontWeight": "normal"
@@ -10096,7 +9424,7 @@
100969424 {
100979425 "type": "frame",
100989426 "id": "OPa0l",
10099 "name": "NEW PASSPHRASE Field",
9427 "name": "NEW PASSWORD Field",
101009428 "width": "fill_container",
101019429 "layout": "vertical",
101029430 "gap": 7,
@@ -10249,7 +9577,7 @@
102499577 {
102509578 "type": "frame",
102519579 "id": "Tv0ZM",
10252 "name": "CONFIRM PASSPHRASE Field",
9580 "name": "CONFIRM PASSWORD Field",
102539581 "width": "fill_container",
102549582 "layout": "vertical",
102559583 "gap": 7,
@@ -10361,7 +9689,7 @@
103619689 "id": "iXgzi",
103629690 "name": "Label",
103639691 "fill": "#111111",
10364 "content": "Update passphrase",
9692 "content": "Update password",
103659693 "fontFamily": "JetBrains Mono",
103669694 "fontSize": 14,
103679695 "fontWeight": "bold",
@@ -10416,27 +9744,6 @@
104169744 },
104179745 {
104189746 "type": "text",
10419 "id": "jxBi7",
10420 "name": "Status",
10421 "fill": "$--muted-foreground",
10422 "content": "ALL SYSTEMS OPERATIONAL",
10423 "fontFamily": "$--font-primary",
10424 "fontSize": 11,
10425 "fontWeight": "normal",
10426 "letterSpacing": 0.5
10427 },
10428 {
10429 "type": "text",
10430 "id": "XaeQp",
10431 "name": "Sep",
10432 "fill": "$--border",
10433 "content": "·",
10434 "fontFamily": "$--font-primary",
10435 "fontSize": 11,
10436 "fontWeight": "normal"
10437 },
10438 {
10439 "type": "text",
104409747 "id": "sQCgK",
104419748 "name": "Region",
104429749 "fill": "$--muted-foreground",
@@ -10487,7 +9794,7 @@
104879794 "id": "XeLhq",
104889795 "name": "Hint Text",
104899796 "fill": "$--muted-foreground",
10490 "content": "ALL OTHER SESSIONS WILL BE SIGNED OUT",
9797 "content": "EVERY SIGNED-IN DEVICE MUST SIGN IN AGAIN",
104919798 "fontFamily": "$--font-primary",
104929799 "fontSize": 10,
104939800 "fontWeight": "normal",
@@ -10806,7 +10113,7 @@
1080610113 "fill": "#666666",
1080710114 "textGrowth": "fixed-width",
1080810115 "width": "fill_container",
10809 "content": "Recovery token verified. Your new password is checked against the server-side policy.",
10116 "content": "Link verified. Choose your new password.",
1081010117 "lineHeight": 1.5,
1081110118 "textAlign": "center",
1081210119 "fontFamily": "Geist",
@@ -10860,7 +10167,7 @@
1086010167 "id": "BoJxS",
1086110168 "name": "Email",
1086210169 "fill": "#111111",
10863 "content": "ada.lock@kernel.dev",
10170 "content": "ada.lock@gitdata.ai",
1086410171 "fontFamily": "JetBrains Mono",
1086510172 "fontSize": 12.5,
1086610173 "fontWeight": "normal"
@@ -10893,7 +10200,7 @@
1089310200 {
1089410201 "type": "frame",
1089510202 "id": "HFOtH",
10896 "name": "NEW PASSPHRASE Field",
10203 "name": "NEW PASSWORD Field",
1089710204 "width": "fill_container",
1089810205 "layout": "vertical",
1089910206 "gap": 7,
@@ -11046,7 +10353,7 @@
1104610353 {
1104710354 "type": "frame",
1104810355 "id": "ZKUnG",
11049 "name": "CONFIRM PASSPHRASE Field",
10356 "name": "CONFIRM PASSWORD Field",
1105010357 "width": "fill_container",
1105110358 "layout": "vertical",
1105210359 "gap": 7,
@@ -11158,7 +10465,7 @@
1115810465 "id": "nE26k",
1115910466 "name": "Label",
1116010467 "fill": "#111111",
11161 "content": "Update passphrase",
10468 "content": "Update password",
1116210469 "fontFamily": "JetBrains Mono",
1116310470 "fontSize": 14,
1116410471 "fontWeight": "bold",
@@ -11213,27 +10520,6 @@
1121310520 },
1121410521 {
1121510522 "type": "text",
11216 "id": "L5quz",
11217 "name": "Status",
11218 "fill": "$--muted-foreground",
11219 "content": "ALL SYSTEMS OPERATIONAL",
11220 "fontFamily": "$--font-primary",
11221 "fontSize": 11,
11222 "fontWeight": "normal",
11223 "letterSpacing": 0.5
11224 },
11225 {
11226 "type": "text",
11227 "id": "CSE4l",
11228 "name": "Sep",
11229 "fill": "$--border",
11230 "content": "·",
11231 "fontFamily": "$--font-primary",
11232 "fontSize": 11,
11233 "fontWeight": "normal"
11234 },
11235 {
11236 "type": "text",
1123710523 "id": "nXHaG",
1123810524 "name": "Copy",
1123910525 "fill": "$--muted-foreground",
@@ -11263,7 +10549,7 @@
1126310549 "id": "R5HuR3",
1126410550 "name": "Hint Text",
1126510551 "fill": "$--muted-foreground",
11266 "content": "ALL OTHER SESSIONS WILL BE SIGNED OUT",
10552 "content": "EVERY SIGNED-IN DEVICE MUST SIGN IN AGAIN",
1126710553 "fontFamily": "$--font-primary",
1126810554 "fontSize": 10,
1126910555 "fontWeight": "normal",
@@ -11549,7 +10835,7 @@
1154910835 "id": "N3EEC",
1155010836 "name": "Email",
1155110837 "fill": "#111111",
11552 "content": "ada.lock@kernel.dev",
10838 "content": "ada.lock@gitdata.ai",
1155310839 "fontFamily": "JetBrains Mono",
1155410840 "fontSize": 12.5,
1155510841 "fontWeight": "normal"
@@ -11574,7 +10860,7 @@
1157410860 "id": "QK4yg",
1157510861 "name": "Text",
1157610862 "fill": "#666666",
11577 "content": "PURPOSE · SIGNUP",
10863 "content": "PURPOSE · SIGN-UP",
1157810864 "fontFamily": "JetBrains Mono",
1157910865 "fontSize": 9.5,
1158010866 "fontWeight": "bold",
@@ -11591,7 +10877,7 @@
1159110877 "fill": "#666666",
1159210878 "textGrowth": "fixed-width",
1159310879 "width": "fill_container",
11594 "content": "The link expires in 24 hours. Open it on this device to continue.",
10880 "content": "The code and link in the email expire in 10 minutes.",
1159510881 "lineHeight": 1.5,
1159610882 "textAlign": "center",
1159710883 "fontFamily": "Geist",
@@ -11636,7 +10922,7 @@
1163610922 "id": "qxsl5",
1163710923 "name": "Label",
1163810924 "fill": "#111111",
11639 "content": "I have verified my email",
10925 "content": "Confirm verification",
1164010926 "fontFamily": "JetBrains Mono",
1164110927 "fontSize": 14,
1164210928 "fontWeight": "bold",
@@ -11794,27 +11080,6 @@
1179411080 },
1179511081 {
1179611082 "type": "text",
11797 "id": "ChwGz",
11798 "name": "Status",
11799 "fill": "$--muted-foreground",
11800 "content": "ALL SYSTEMS OPERATIONAL",
11801 "fontFamily": "$--font-primary",
11802 "fontSize": 11,
11803 "fontWeight": "normal",
11804 "letterSpacing": 0.5
11805 },
11806 {
11807 "type": "text",
11808 "id": "Y2zaf",
11809 "name": "Sep",
11810 "fill": "$--border",
11811 "content": "·",
11812 "fontFamily": "$--font-primary",
11813 "fontSize": 11,
11814 "fontWeight": "normal"
11815 },
11816 {
11817 "type": "text",
1181811083 "id": "JLK1q",
1181911084 "name": "Region",
1182011085 "fill": "$--muted-foreground",
@@ -11865,7 +11130,7 @@
1186511130 "id": "YB9ud",
1186611131 "name": "Hint Text",
1186711132 "fill": "$--muted-foreground",
11868 "content": "CHECK SPAM FOLDER · SMTP DELIVERY ~5S",
11133 "content": "NO EMAIL YET? CHECK YOUR SPAM FOLDER",
1186911134 "fontFamily": "$--font-primary",
1187011135 "fontSize": 10,
1187111136 "fontWeight": "normal",
@@ -12229,7 +11494,7 @@
1222911494 "id": "WbOFa",
1223011495 "name": "Email",
1223111496 "fill": "#111111",
12232 "content": "ada.lock@kernel.dev",
11497 "content": "ada.lock@gitdata.ai",
1223311498 "fontFamily": "JetBrains Mono",
1223411499 "fontSize": 12.5,
1223511500 "fontWeight": "normal"
@@ -12254,7 +11519,7 @@
1225411519 "id": "WqtVp",
1225511520 "name": "Text",
1225611521 "fill": "#666666",
12257 "content": "PURPOSE · SIGNUP",
11522 "content": "PURPOSE · SIGN-UP",
1225811523 "fontFamily": "JetBrains Mono",
1225911524 "fontSize": 9.5,
1226011525 "fontWeight": "bold",
@@ -12271,7 +11536,7 @@
1227111536 "fill": "#666666",
1227211537 "textGrowth": "fixed-width",
1227311538 "width": "fill_container",
12274 "content": "The link expires in 24 hours. Open it on this device to continue.",
11539 "content": "The code and link in the email expire in 10 minutes.",
1227511540 "lineHeight": 1.5,
1227611541 "textAlign": "center",
1227711542 "fontFamily": "Geist",
@@ -12316,7 +11581,7 @@
1231611581 "id": "qh2xp",
1231711582 "name": "Label",
1231811583 "fill": "#111111",
12319 "content": "I have verified my email",
11584 "content": "Confirm verification",
1232011585 "fontFamily": "JetBrains Mono",
1232111586 "fontSize": 14,
1232211587 "fontWeight": "bold",
@@ -12474,27 +11739,6 @@
1247411739 },
1247511740 {
1247611741 "type": "text",
12477 "id": "G44tQN",
12478 "name": "Status",
12479 "fill": "$--muted-foreground",
12480 "content": "ALL SYSTEMS OPERATIONAL",
12481 "fontFamily": "$--font-primary",
12482 "fontSize": 11,
12483 "fontWeight": "normal",
12484 "letterSpacing": 0.5
12485 },
12486 {
12487 "type": "text",
12488 "id": "iu9zv",
12489 "name": "Sep",
12490 "fill": "$--border",
12491 "content": "·",
12492 "fontFamily": "$--font-primary",
12493 "fontSize": 11,
12494 "fontWeight": "normal"
12495 },
12496 {
12497 "type": "text",
1249811742 "id": "UX0h6",
1249911743 "name": "Copy",
1250011744 "fill": "$--muted-foreground",
@@ -12524,7 +11768,7 @@
1252411768 "id": "h6qs0O",
1252511769 "name": "Hint Text",
1252611770 "fill": "$--muted-foreground",
12527 "content": "CHECK SPAM FOLDER · SMTP DELIVERY ~5S",
11771 "content": "NO EMAIL YET? CHECK YOUR SPAM FOLDER",
1252811772 "fontFamily": "$--font-primary",
1252911773 "fontSize": 10,
1253011774 "fontWeight": "normal",
@@ -12765,7 +12009,7 @@
1276512009 "fill": "#666666",
1276612010 "textGrowth": "fixed-width",
1276712011 "width": "fill_container",
12768 "content": "Enter the 6-digit code from your authenticator app for account ada.lock@kernel.dev.",
12012 "content": "Enter the 6-digit code from your authenticator app.",
1276912013 "lineHeight": 1.5,
1277012014 "textAlign": "center",
1277112015 "fontFamily": "Geist",
@@ -13122,27 +12366,6 @@
1312212366 },
1312312367 {
1312412368 "type": "text",
13125 "id": "pXlwp",
13126 "name": "Status",
13127 "fill": "$--muted-foreground",
13128 "content": "ALL SYSTEMS OPERATIONAL",
13129 "fontFamily": "$--font-primary",
13130 "fontSize": 11,
13131 "fontWeight": "normal",
13132 "letterSpacing": 0.5
13133 },
13134 {
13135 "type": "text",
13136 "id": "BWgV3",
13137 "name": "Sep",
13138 "fill": "$--border",
13139 "content": "·",
13140 "fontFamily": "$--font-primary",
13141 "fontSize": 11,
13142 "fontWeight": "normal"
13143 },
13144 {
13145 "type": "text",
1314612369 "id": "LhWfq",
1314712370 "name": "Region",
1314812371 "fill": "$--muted-foreground",
@@ -13193,7 +12416,7 @@
1319312416 "id": "JktXA",
1319412417 "name": "Hint Text",
1319512418 "fill": "$--muted-foreground",
13196 "content": "CHALLENGE EXPIRES IN 5 MIN · 5 ATTEMPTS MAX",
12419 "content": "CODE VALID FOR 5 MIN · 5 ATTEMPTS MAX",
1319712420 "fontFamily": "$--font-primary",
1319812421 "fontSize": 10,
1319912422 "fontWeight": "normal",
@@ -13510,7 +12733,7 @@
1351012733 "fill": "#666666",
1351112734 "textGrowth": "fixed-width",
1351212735 "width": "fill_container",
13513 "content": "Enter the 6-digit code from your authenticator app for account ada.lock@kernel.dev.",
12736 "content": "Enter the 6-digit code from your authenticator app.",
1351412737 "lineHeight": 1.5,
1351512738 "textAlign": "center",
1351612739 "fontFamily": "Geist",
@@ -13868,27 +13091,6 @@
1386813091 },
1386913092 {
1387013093 "type": "text",
13871 "id": "FHbrU",
13872 "name": "Status",
13873 "fill": "$--muted-foreground",
13874 "content": "ALL SYSTEMS OPERATIONAL",
13875 "fontFamily": "$--font-primary",
13876 "fontSize": 11,
13877 "fontWeight": "normal",
13878 "letterSpacing": 0.5
13879 },
13880 {
13881 "type": "text",
13882 "id": "Z6aqlr",
13883 "name": "Sep",
13884 "fill": "$--border",
13885 "content": "·",
13886 "fontFamily": "$--font-primary",
13887 "fontSize": 11,
13888 "fontWeight": "normal"
13889 },
13890 {
13891 "type": "text",
1389213094 "id": "gUpu5",
1389313095 "name": "Copy",
1389413096 "fill": "$--muted-foreground",
@@ -13918,7 +13120,7 @@
1391813120 "id": "GE7mO",
1391913121 "name": "Hint Text",
1392013122 "fill": "$--muted-foreground",
13921 "content": "CHALLENGE EXPIRES IN 5 MIN · 5 ATTEMPTS MAX",
13123 "content": "CODE VALID FOR 5 MIN · 5 ATTEMPTS MAX",
1392213124 "fontFamily": "$--font-primary",
1392313125 "fontSize": 10,
1392413126 "fontWeight": "normal",
@@ -14518,4 +13720,4 @@
1451813720 }
1451913721 },
1452013722 "fileToken": "211b7cb5-195c-47c3-ae9a-aae9097c1424"
14521}
No newline at end of file
13723}
Modified web/src/features/auth/ui/auth-shell.tsx web/src/features/auth/ui/auth-shell.tsx +1 −15 View web/src/features/auth/ui/auth-shell.tsx
@@ -13,8 +13,6 @@
1313 children: ReactNode
1414}
1515
16const edgeLabel =
17 'pointer-events-none absolute top-1/2 hidden rotate-90 font-mono text-[10px] tracking-[1.6px] text-muted-foreground xl:block'
1816const mark = 'pointer-events-none absolute size-[18px] border-border'
1917const markTL = 'left-4 top-4 sm:left-6 sm:top-6 lg:left-9 lg:top-9'
2018const markTR = 'right-4 top-4 sm:right-6 sm:top-6 lg:right-9 lg:top-9'
@@ -48,13 +46,6 @@
4846 <div aria-hidden className={`${mark} ${markTR} border-r border-t`} />
4947 <div aria-hidden className={`${mark} ${markBL} border-b border-l`} />
5048 <div aria-hidden className={`${mark} ${markBR} border-b border-r`} />
51
52 <div aria-hidden className={`${edgeLabel} left-10 -translate-y-1/2`}>
53 AUTH · v4.2.0
54 </div>
55 <div aria-hidden className={`${edgeLabel} right-10 -translate-y-1/2`}>
56 TLS 1.3 · TOTP-SHA1
57 </div>
5849
5950 <nav
6051 aria-label={t('auth.shell.breadcrumb')}
@@ -86,12 +77,7 @@
8677 {hint}
8778 </p>
8879 <p className="flex items-center gap-2.5 font-mono text-[11px] tracking-[0.5px] text-muted-foreground">
89 <span className="size-[7px] shrink-0 rounded-full bg-success-foreground" aria-hidden />
90 <span>{t('auth.shell.operational').toUpperCase()}</span>
91 <span className="hidden text-border sm:inline">·</span>
92 <span className="hidden sm:inline">
93 {t('auth.shell.region', { region }).toUpperCase()}
94 </span>
80 <span>{t('auth.shell.region', { region }).toUpperCase()}</span>
9581 <span className="text-border">·</span>
9682 <span>© 2026 GitDataAI</span>
9783 </p>
Modified web/src/features/space-management/model/space-admin.ts web/src/features/space-management/model/space-admin.ts +26 −8 View web/src/features/space-management/model/space-admin.ts
@@ -8,8 +8,8 @@
88 * schema, so each row type is asserted here from its single-resource envelope
99 * (`AppResponseData_MemberDto` etc.) instead of being read off the list call.
1010 */
11import Apis from '@/shared/api'
12import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
11import Apis, { trackFetch } from '@/shared/api'
12import { ApiFailure, unwrapApi, type ApiEnvelope } from '@/shared/lib'
1313import type {
1414 AcceptInviteCmd,
1515 AddMemberCmd,
@@ -33,7 +33,6 @@
3333 UpdateGroupCmd,
3434 UpdateJoinRuleCmd,
3535 UpdateMemberCmd,
36 UploadSpaceImageCmd,
3736} from '@/shared/api'
3837
3938export type SpaceMember = NonNullable<AppResponseData_MemberDto['data']>
@@ -290,14 +289,33 @@
290289 Apis.space.get_space_image({ pathParams: { space_name: spaceName, kind } }),
291290 )
292291
293export const uploadSpaceImage = (
292/** Binary upload, same contract as `PUT /me/avatar`: the raw file bytes are the
293 * request body, so the server never decodes base64, and the intrinsic size rides
294 * along as query params because it cannot live in a byte stream. */
295export async function uploadSpaceImage(
294296 spaceName: string,
295297 kind: SpaceImageKind,
296 cmd: UploadSpaceImageCmd,
297) =>
298 unwrapApi<SpaceImage>(
299 Apis.space.upload_space_image({ pathParams: { space_name: spaceName, kind }, data: cmd }),
298 file: File,
299 size?: { width: number; height: number } | null,
300): Promise<SpaceImage> {
301 const query = new URLSearchParams()
302 if (size?.width) query.set('width', String(size.width))
303 if (size?.height) query.set('height', String(size.height))
304 const params = query.toString()
305 const res = await trackFetch(
306 `/api/v1/spaces/${encodeURIComponent(spaceName)}/images/${kind}${params ? `?${params}` : ''}`,
307 {
308 method: 'PUT',
309 body: file,
310 headers: { 'content-type': file.type || 'application/octet-stream' },
311 },
300312 )
313 const env = (await res.json()) as ApiEnvelope<SpaceImage>
314 if (env.code !== 200) {
315 throw new ApiFailure(env.error || env.message || 'space image upload failed', env.code, env.error_code)
316 }
317 return env.data as SpaceImage
318}
301319
302320export const deleteSpaceImage = (spaceName: string, kind: SpaceImageKind) =>
303321 unwrapApi<null>(Apis.space.delete_space_image({ pathParams: { space_name: spaceName, kind } }))
Modified web/src/features/space-management/ui/space-settings/images-section.tsx web/src/features/space-management/ui/space-settings/images-section.tsx +1 −8 View web/src/features/space-management/ui/space-settings/images-section.tsx
@@ -5,7 +5,6 @@
55 SectionTitle,
66 SettingRow,
77 fmtDate,
8 fileToBase64,
98 useSave,
109} from '@/shared/ui'
1110import { spaceAdminKey, useAsync } from '@/shared/lib'
@@ -83,14 +82,8 @@
8382 const pick = (file: File) =>
8483 void run(async () => {
8584 if (file.size > MAX_BYTES) throw new Error(t('space.images.tooLarge'))
86 const data = await fileToBase64(file)
8785 const size = await readImageSize(file)
88 await uploadSpaceImage(spaceName, kind, {
89 content_type: file.type || 'image/png',
90 data_base64: data,
91 width: size?.width ?? null,
92 height: size?.height ?? null,
93 })
86 await uploadSpaceImage(spaceName, kind, file, size)
9487 onSaved()
9588 // The header avatar and the rail tile read the space row, not this call.
9689 onSpaceUpdated()
Modified web/src/pages/email-verification/ui/email-verification-page.tsx web/src/pages/email-verification/ui/email-verification-page.tsx +43 −19 View web/src/pages/email-verification/ui/email-verification-page.tsx
@@ -10,6 +10,7 @@
1010 Chip,
1111 IconBadge,
1212 Pill,
13 forgotPassword,
1314 getPowAnswer,
1415 resendCode,
1516 toUserError,
@@ -17,7 +18,7 @@
1718 type EmailVerificationPurpose,
1819} from '@/features/auth'
1920
20const RESEND_COOLDOWN_SECS = 45
21const RESEND_COOLDOWN_SECS = 60
2122
2223function formatCountdown(secs: number) {
2324 const m = String(Math.floor(secs / 60)).padStart(2, '0')
@@ -44,6 +45,7 @@
4445 const [error, setError] = useState<string | null>(null)
4546 const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECS)
4647
48 const isReset = purpose === 'password_reset'
4749 const counting = cooldown > 0
4850 useEffect(() => {
4951 if (!counting) return
@@ -72,7 +74,13 @@
7274 setError(null)
7375 try {
7476 const powAnswer = await getPowAnswer()
75 await resendCode({ email, purpose, pow_answer: powAnswer })
77 if (isReset) {
78 // The reset flow is link-based: re-issue the reset mail instead of
79 // sending a verification code the user cannot redeem.
80 await forgotPassword({ email, pow_answer: powAnswer })
81 } else {
82 await resendCode({ email, purpose, pow_answer: powAnswer })
83 }
7684 setResent(true)
7785 setCooldown(RESEND_COOLDOWN_SECS)
7886 } catch (err) {
@@ -90,36 +98,48 @@
9098 <AuthCard
9199 badge={<IconBadge><Mail size={20} aria-hidden /></IconBadge>}
92100 heading={t('auth.verify.heading')}
93 subheading={t('auth.verify.subheading')}
101 subheading={isReset ? t('auth.verify.resetSubheading') : t('auth.verify.subheading')}
94102 headerExtra={
95103 <div className="flex w-full flex-wrap items-center justify-center gap-2">
96104 <Chip className="font-mono text-[12.5px] text-foreground">
97105 <AtSign size={13} aria-hidden className="text-muted-foreground" />
98106 {email || t('auth.reset.noAddress')}
99107 </Chip>
100 <Pill>{t('auth.verify.purpose', { purpose })}</Pill>
108 <Pill>
109 {t('auth.verify.purpose', { purpose: t(`auth.verify.purpose.${purpose}`) })}
110 </Pill>
101111 </div>
102112 }
103113 >
104114 <div className="flex w-full flex-col gap-[15px]">
105115 <p className="text-center text-[12.5px] leading-[1.5] text-muted-foreground">
106 {code ? t('auth.verify.linkOpened') : t('auth.verify.linkExpiry')}
116 {isReset
117 ? t('auth.verify.resetBody')
118 : code
119 ? t('auth.verify.linkOpened')
120 : t('auth.verify.linkExpiry')}
107121 </p>
108122 {verified && <Alert tone="success">{t('auth.verify.verified')}</Alert>}
109123 {resent && !verified && (
110 <Alert tone="success">{t('auth.verify.resent', { email })}</Alert>
124 <Alert tone="success">
125 {isReset
126 ? t('auth.verify.resetResent', { email })
127 : t('auth.verify.resent', { email })}
128 </Alert>
111129 )}
112130 {error && <Alert tone="error">{error}</Alert>}
113 {!code && !error && <Alert tone="info">{t('auth.verify.waiting')}</Alert>}
114 <Button
115 size="lg"
116 className="w-full"
117 disabled={busy || verified || !code}
118 onClick={onConfirm}
119 >
120 {busy ? t('common.action.working') : t('auth.verify.confirm')}
121 <ArrowRight size={15} aria-hidden />
122 </Button>
131 {!code && !isReset && !error && <Alert tone="info">{t('auth.verify.waiting')}</Alert>}
132 {!isReset && (
133 <Button
134 size="lg"
135 className="w-full"
136 disabled={busy || verified || !code}
137 onClick={onConfirm}
138 >
139 {busy ? t('common.action.working') : t('auth.verify.confirm')}
140 <ArrowRight size={15} aria-hidden />
141 </Button>
142 )}
123143 <LabeledDivider label={t('auth.verify.divider')} />
124144 <Button
125145 variant="secondary"
@@ -128,9 +148,13 @@
128148 onClick={onResend}
129149 >
130150 <RefreshCw size={15} aria-hidden />
131 {cooldown > 0
132 ? t('auth.verify.resendCountdown', { time: formatCountdown(cooldown) })
133 : t('auth.verify.resend')}
151 {isReset
152 ? cooldown > 0
153 ? t('auth.verify.resetResendCountdown', { time: formatCountdown(cooldown) })
154 : t('auth.verify.resetResend')
155 : cooldown > 0
156 ? t('auth.verify.resendCountdown', { time: formatCountdown(cooldown) })
157 : t('auth.verify.resend')}
134158 </Button>
135159 <AuthFooter
136160 question={t('auth.verify.wrongAddress')}
Modified web/src/pages/forgot-password/ui/forgot-password-page.tsx web/src/pages/forgot-password/ui/forgot-password-page.tsx +1 −1 View web/src/pages/forgot-password/ui/forgot-password-page.tsx
@@ -51,7 +51,7 @@
5151 label={t('auth.forgot.email')}
5252 icon={AtSign}
5353 type="email"
54 placeholder="ada.lock@kernel.dev"
54 placeholder="ada.lock@gitdata.ai"
5555 autoComplete="email"
5656 value={email}
5757 onChange={e => setEmail(e.target.value)}
Modified web/src/pages/login/ui/login-page.tsx web/src/pages/login/ui/login-page.tsx +12 −22 View web/src/pages/login/ui/login-page.tsx
@@ -1,6 +1,6 @@
11import { useState, type FormEvent } from 'react'
22import { Link, useNavigate } from 'react-router-dom'
3import { ArrowRight, ArrowUpRight, AtSign, Lock, Mail, Shield } from 'lucide-react'
3import { ArrowRight, ArrowUpRight, AtSign, Lock, Mail } from 'lucide-react'
44import { useT } from '@/shared/i18n'
55import { Alert, Button, Checkbox, LabeledDivider, RevealToggle, TextField } from '@/shared/ui'
66import { setCacheOwner } from '@/shared/storage'
@@ -31,7 +31,7 @@
3131 setError(null)
3232 try {
3333 const powAnswer = await getPowAnswer()
34 const result = await login({ identifier, password, pow_answer: powAnswer })
34 const result = await login({ identifier, password, pow_answer: powAnswer, remember })
3535 if (result?.needs_2fa) {
3636 navigate('/auth/2fa', { state: { challenge: result.challenge ?? null } })
3737 } else {
@@ -97,26 +97,16 @@
9797 <ArrowRight size={15} aria-hidden />
9898 </Button>
9999 <LabeledDivider label={t('auth.login.losingAccess')} />
100 <div className="flex w-full flex-col gap-3 sm:flex-row">
101 <Button
102 variant="secondary"
103 className="h-11 flex-1 text-[13.5px] font-normal"
104 onClick={() => navigate('/auth/2fa', { state: { challenge: null, recovery: true } })}
105 >
106 <Shield size={15} aria-hidden />
107 {t('auth.login.recoveryCode')}
108 </Button>
109 <Button
110 variant="secondary"
111 className="h-11 flex-1 text-[13.5px] font-normal"
112 onClick={() =>
113 navigate(`/auth/verify-email?purpose=signup&email=${encodeURIComponent(identifier)}`)
114 }
115 >
116 <Mail size={15} aria-hidden />
117 {t('auth.login.resendVerify')}
118 </Button>
119 </div>
100 <Button
101 variant="secondary"
102 className="h-11 w-full text-[13.5px] font-normal"
103 onClick={() =>
104 navigate(`/auth/verify-email?purpose=signup&email=${encodeURIComponent(identifier)}`)
105 }
106 >
107 <Mail size={15} aria-hidden />
108 {t('auth.login.resendVerify')}
109 </Button>
120110 <AuthFooter
121111 question={t('auth.login.noAccount')}
122112 action={t('auth.login.requestAccess')}
Modified web/src/pages/reset-password/ui/reset-password-page.tsx web/src/pages/reset-password/ui/reset-password-page.tsx +3 −1 View web/src/pages/reset-password/ui/reset-password-page.tsx
@@ -72,7 +72,9 @@
7272 <AuthCard
7373 badge={<IconBadge><ShieldCheck size={20} aria-hidden /></IconBadge>}
7474 heading={t('auth.reset.heading')}
75 subheading={t('auth.reset.subheading')}
75 subheading={
76 tokenValid ? t('auth.reset.subheading') : t('auth.reset.subheadingInvalid')
77 }
7678 headerExtra={
7779 <div className="flex w-full items-center justify-center gap-2">
7880 <Chip className="font-mono text-[12.5px] text-foreground">
Modified web/src/pages/sign-up/ui/sign-up-page.tsx web/src/pages/sign-up/ui/sign-up-page.tsx +13 −7 View web/src/pages/sign-up/ui/sign-up-page.tsx
@@ -90,13 +90,12 @@
9090 setError(t('auth.signup.error.terms'))
9191 return
9292 }
93 const dobStarted = Boolean(day || month || year)
9493 const dobComplete = Boolean(day && month && year)
95 if (dobStarted && !dobComplete) {
94 if (!dobComplete) {
9695 setError(t('auth.signup.error.dobIncomplete'))
9796 return
9897 }
99 if (dobComplete && ageFrom(year, month, day) < MIN_AGE) {
98 if (ageFrom(year, month, day) < MIN_AGE) {
10099 setError(t('auth.signup.error.age', { age: MIN_AGE }))
101100 return
102101 }
@@ -133,7 +132,7 @@
133132 return (
134133 <AuthShell
135134 current={t('auth.signup.crumb').toUpperCase()}
136 hint={t('auth.signup.hint').toUpperCase()}
135 hint={t('auth.signup.hint', { age: MIN_AGE }).toUpperCase()}
137136 >
138137 <AuthCard
139138 badge={<KernelMark />}
@@ -153,7 +152,7 @@
153152 label={t('auth.signup.email')}
154153 icon={AtSign}
155154 type="email"
156 placeholder="ada.lock@kernel.dev"
155 placeholder="ada.lock@gitdata.ai"
157156 autoComplete="email"
158157 value={email}
159158 onChange={e => setEmail(e.target.value)}
@@ -170,6 +169,9 @@
170169 required
171170 trailing={<RevealToggle revealed={revealed} onToggle={() => setRevealed(r => !r)} />}
172171 />
172 <p className="text-[12px] leading-[1.5] text-muted-foreground">
173 {t('auth.signup.passwordHint')}
174 </p>
173175 <div className="flex w-full flex-col gap-[7px]">
174176 <div className="flex w-full items-center justify-between">
175177 <span className="font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-muted-foreground">
@@ -218,7 +220,9 @@
218220 <span className="cursor-pointer" onClick={() => setTerms(v => !v)}>
219221 {t('auth.signup.termsPrefix')}{' '}
220222 <a
221 href="#terms"
223 href="/pages/agreements/terms"
224 target="_blank"
225 rel="noreferrer"
222226 className="font-bold underline-offset-4 hover:underline"
223227 onClick={e => e.stopPropagation()}
224228 >
@@ -226,7 +230,9 @@
226230 </a>{' '}
227231 {t('auth.signup.and')}{' '}
228232 <a
229 href="#privacy"
233 href="/pages/agreements/privacy"
234 target="_blank"
235 rel="noreferrer"
230236 className="font-bold underline-offset-4 hover:underline"
231237 onClick={e => e.stopPropagation()}
232238 >
Modified web/src/shared/api/apiDefinitions.ts web/src/shared/api/apiDefinitions.ts +1 −0 View web/src/shared/api/apiDefinitions.ts
@@ -584,6 +584,7 @@
584584 'space.get_space_image': ['GET', '/spaces/{space_name}/images/{kind}'],
585585 'space.upload_space_image': ['PUT', '/spaces/{space_name}/images/{kind}'],
586586 'space.delete_space_image': ['DELETE', '/spaces/{space_name}/images/{kind}'],
587 'space.get_space_image_file': ['GET', '/spaces/{space_name}/images/{kind}/file'],
587588 'space.list_invites': ['GET', '/spaces/{space_name}/invites'],
588589 'space.create_invite': ['POST', '/spaces/{space_name}/invites'],
589590 'space.revoke_invite': ['DELETE', '/spaces/{space_name}/invites/{invite_id}'],
Modified web/src/shared/api/globals.d.ts web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts
Diff too large to display.
Modified web/src/shared/i18n/messages/en/auth.ts web/src/shared/i18n/messages/en/auth.ts +54 −49 View web/src/shared/i18n/messages/en/auth.ts
@@ -3,31 +3,30 @@
33 'auth.shell.breadcrumb': 'Breadcrumb',
44 'auth.shell.switchToLight': 'Switch to light theme',
55 'auth.shell.switchToDark': 'Switch to dark theme',
6 'auth.shell.operational': 'All systems operational',
76 'auth.shell.region': 'Region {region}',
87 'auth.login.crumb': 'Sign in',
9 'auth.login.hint': 'Proof-of-work solved silently · 5 attempts / 5 min',
10 'auth.login.heading': 'Sign in to Kernel',
11 'auth.login.subheading': 'Authenticate to reach the control plane and your running clusters.',
8 'auth.login.hint': 'Up to 5 attempts per account every 5 minutes',
9 'auth.login.heading': 'Sign in to GitDataAI',
10 'auth.login.subheading': 'Repositories, boards and team chat in one place.',
1211 'auth.login.identifier': 'Email or username',
13 'auth.login.passphrase': 'Passphrase',
14 'auth.login.forgot': 'Forgot?',
12 'auth.login.passphrase': 'Password',
13 'auth.login.forgot': 'Forgot password?',
1514 'auth.login.keepSignedIn': 'Keep me signed in',
1615 'auth.login.keepSignedInAria': 'Keep me signed in',
1716 'auth.login.submit': 'Sign in',
18 'auth.login.losingAccess': 'Losing access?',
19 'auth.login.recoveryCode': 'Recovery code',
20 'auth.login.resendVerify': 'Resend verify email',
17 'auth.login.losingAccess': 'Trouble signing in?',
18 'auth.login.resendVerify': 'Resend verification email',
2119 'auth.login.noAccount': 'No account yet?',
22 'auth.login.requestAccess': 'Request access',
20 'auth.login.requestAccess': 'Create account',
2321 'auth.signup.crumb': 'Create account',
24 'auth.signup.hint': 'Password policy & age gate enforced server-side',
22 'auth.signup.hint': 'You must be {age}+ · password at least 8 characters',
2523 'auth.signup.heading': 'Create your account',
26 'auth.signup.subheading': 'Provision team access to the Kernel control plane and running clusters.',
24 'auth.signup.subheading': 'One account for repositories, boards and team chat.',
2725 'auth.signup.username': 'Username (optional)',
2826 'auth.signup.email': 'Email',
2927 'auth.signup.password': 'Password',
30 'auth.signup.dob': 'Date of birth (optional)',
28 'auth.signup.passwordHint': 'At least 8 characters with upper case, lower case and a digit',
29 'auth.signup.dob': 'Date of birth',
3130 'auth.signup.dobRequired': '{age}+ required',
3231 'auth.signup.day': 'Day',
3332 'auth.signup.month': 'Month',
@@ -44,67 +43,73 @@
4443 'auth.signup.error.dobIncomplete': 'Please complete your date of birth.',
4544 'auth.signup.error.age': 'You must be at least {age} years old to sign up.',
4645 'auth.forgot.crumb': 'Recover access',
47 'auth.forgot.hint': 'Recovery links expire after 15 minutes',
48 'auth.forgot.heading': 'Forgot passphrase?',
46 'auth.forgot.hint': 'Reset links expire after 30 minutes',
47 'auth.forgot.heading': 'Forgot password?',
4948 'auth.forgot.subheading':
50 'Enter your workspace email and we will send a single-use recovery link. It expires after 15 minutes.',
49 'Enter your account email and we will send a single-use reset link. It expires after 30 minutes.',
5150 'auth.forgot.email': 'Account email',
5251 'auth.forgot.privacyNote':
53 'We always confirm delivery — existing accounts are never revealed.',
54 'auth.forgot.submit': 'Send recovery link',
52 'If the address is registered you will receive a reset email. For security we never reveal whether an account exists.',
53 'auth.forgot.submit': 'Send reset link',
5554 'auth.forgot.remembered': 'Remembered it?',
5655 'auth.forgot.backToSignIn': 'Back to sign in',
57 'auth.reset.crumb': 'New password',
58 'auth.reset.hint': 'All other sessions will be signed out',
56 'auth.reset.crumb': 'Reset password',
57 'auth.reset.hint': 'Every signed-in device must sign in again',
5958 'auth.reset.heading': 'Set a new password',
60 'auth.reset.subheading':
61 'Recovery token verified. Your new password is checked against the server-side policy.',
59 'auth.reset.subheading': 'Link verified. Choose your new password.',
60 'auth.reset.subheadingInvalid': 'Open the reset link from your email to continue.',
6261 'auth.reset.noAddress': 'no address',
63 'auth.reset.tokenValid': 'Token valid',
64 'auth.reset.noToken': 'No token',
62 'auth.reset.tokenValid': 'Link valid',
63 'auth.reset.noToken': 'Link invalid',
6564 'auth.reset.newPassword': 'New password',
6665 'auth.reset.confirmPassword': 'Confirm new password',
6766 'auth.reset.strength.strong': 'Strong',
6867 'auth.reset.strength.fair': 'Fair',
6968 'auth.reset.strength.weak': 'Weak',
7069 'auth.reset.error.mismatch': 'The two passwords do not match.',
71 'auth.reset.noTokenNote': 'Open the recovery link from your email on this device, or',
70 'auth.reset.noTokenNote': 'Open the reset link from your email, or',
7271 'auth.reset.requestNewLink': 'request a new link',
73 'auth.reset.done': 'Passphrase updated — redirecting to sign in…',
74 'auth.reset.submit': 'Update passphrase',
72 'auth.reset.done': 'Password updated — returning to sign in…',
73 'auth.reset.submit': 'Update password',
7574 'auth.verify.crumb': 'Verify email',
76 'auth.verify.hint': 'Check spam folder · SMTP delivery ~5s',
75 'auth.verify.hint': 'No email yet? Check your spam folder',
7776 'auth.verify.heading': 'Check your inbox',
78 'auth.verify.subheading': 'We sent a verification link to',
77 'auth.verify.subheading': 'Verification email sent to',
78 'auth.verify.resetSubheading': 'Reset email sent to',
7979 'auth.verify.purpose': 'purpose · {purpose}',
80 'auth.verify.linkOpened': 'Link opened on this device. Confirm below to continue.',
81 'auth.verify.linkExpiry': 'The link expires in 24 hours. Open it on this device to continue.',
82 'auth.verify.verified': 'Email verified — redirecting to sign in…',
83 'auth.verify.resent': 'A fresh link is on its way to {email}.',
84 'auth.verify.waiting':
85 'Waiting for the link — once you open it on this device, press the button below to confirm.',
86 'auth.verify.confirm': 'I have verified my email',
80 'auth.verify.purpose.signup': 'sign-up',
81 'auth.verify.purpose.password_reset': 'password reset',
82 'auth.verify.purpose.add_email': 'add email',
83 'auth.verify.purpose.reclaim': 'account recovery',
84 'auth.verify.linkOpened': 'Link opened. Confirm below to continue.',
85 'auth.verify.linkExpiry': 'The code and link in the email expire in 10 minutes.',
86 'auth.verify.resetBody': 'Set a new password through the link in the email within 30 minutes.',
87 'auth.verify.verified': 'Email verified — returning to sign in…',
88 'auth.verify.resent': 'A new verification email was sent to {email}.',
89 'auth.verify.resetResent': 'A new reset email was sent to {email}.',
90 'auth.verify.waiting': 'Open the verification link in the email to continue.',
91 'auth.verify.confirm': 'Confirm verification',
8792 'auth.verify.divider': 'Did not arrive?',
88 'auth.verify.resend': 'Resend email',
89 'auth.verify.resendCountdown': 'Resend email ({time})',
93 'auth.verify.resend': 'Resend verification email',
94 'auth.verify.resendCountdown': 'Resend verification email ({time})',
95 'auth.verify.resetResend': 'Resend reset email',
96 'auth.verify.resetResendCountdown': 'Resend reset email ({time})',
9097 'auth.verify.wrongAddress': 'Wrong address?',
91 'auth.verify.changeEmail': 'Change email',
98 'auth.verify.changeEmail': 'Change it',
9299 'auth.twofa.crumb': 'Two-factor',
93 'auth.twofa.hint': 'Challenge expires in 5 min · 5 attempts max',
94 'auth.twofa.heading': 'Two-factor code',
95 'auth.twofa.subheading':
96 'Enter the 6-digit code from your authenticator app for the signed-in account.',
100 'auth.twofa.hint': 'Code valid for 5 min · 5 attempts max',
101 'auth.twofa.heading': 'Two-factor authentication',
102 'auth.twofa.subheading': 'Enter the 6-digit code from your authenticator app.',
97103 'auth.twofa.recoveryCode': 'Recovery code',
98104 'auth.twofa.authCode': 'Authentication code',
99105 'auth.twofa.expiresIn': 'Expires in {time}',
100 'auth.twofa.noChallengeError':
101 'No pending sign-in on this device. Start again from the sign-in screen.',
102 'auth.twofa.noChallengeNote': 'No pending challenge —',
103 'auth.twofa.signIn': 'sign in',
106 'auth.twofa.noChallengeError': 'The sign-in request expired. Start again from the sign-in screen.',
107 'auth.twofa.noChallengeNote': 'Sign-in request expired —',
108 'auth.twofa.signIn': 'sign in again',
104109 'auth.twofa.submit': 'Verify',
105110 'auth.twofa.trouble': 'Trouble?',
106 'auth.twofa.useAuthenticator': 'Use authenticator app',
111 'auth.twofa.useAuthenticator': 'Use authenticator code',
107112 'auth.twofa.useRecovery': 'Use a recovery code',
108 'auth.error.network': 'Network error — check your connection and retry.',
113 'auth.error.network': 'Network connection failed. Check your connection and retry.',
109114 'auth.error.unknown': 'Something went wrong. Please try again.',
110115} as const
Modified web/src/shared/i18n/messages/en/space.ts web/src/shared/i18n/messages/en/space.ts +1 −1 View web/src/shared/i18n/messages/en/space.ts
@@ -253,7 +253,7 @@
253253 'space.danger.confirmLabel': 'Type the space name {name} to confirm',
254254 'space.danger.deleting': 'Deleting…',
255255 'space.danger.confirm': 'Delete space',
256 'space.images.sub': 'Images are uploaded as base64; each file must stay under 5 MB.',
256 'space.images.sub': 'Images are uploaded as raw bytes straight to object storage; each file must stay under 5 MB.',
257257 'space.images.loadFailed': 'Could not load the images — you may lack the manage-channels permission.',
258258 'space.images.tooLarge': 'The image exceeds 5 MB. Compress it and try again.',
259259 'space.images.updated': '{label} updated.',
Modified web/src/shared/i18n/messages/en/userSettings.ts web/src/shared/i18n/messages/en/userSettings.ts +1 −1 View web/src/shared/i18n/messages/en/userSettings.ts
@@ -92,7 +92,7 @@
9292 'userSettings.security.regenerate': 'Regenerate',
9393 'userSettings.security.regenPasswordPlaceholder': 'Current password (required to regenerate)',
9494 'userSettings.security.code6Placeholder': '6-digit code',
95 'userSettings.security.disable': 'Disable 2FA',
95 'userSettings.security.disable': 'Disable two-factor',
9696 'userSettings.ssh.registered': 'Registered keys ({count})',
9797 'userSettings.ssh.none': 'No SSH keys have been added yet.',
9898 'userSettings.ssh.addedAt': 'Added {when}',
Modified web/src/shared/i18n/messages/zh-CN/auth.ts web/src/shared/i18n/messages/zh-CN/auth.ts +52 −43 View web/src/shared/i18n/messages/zh-CN/auth.ts
@@ -3,32 +3,31 @@
33 'auth.shell.breadcrumb': '面包屑导航',
44 'auth.shell.switchToLight': '切换到浅色主题',
55 'auth.shell.switchToDark': '切换到深色主题',
6 'auth.shell.operational': '全部系统正常',
76 'auth.shell.region': '区域 {region}',
87 'auth.login.crumb': '登录',
9 'auth.login.hint': '后台静默完成工作量证明 · 5 次尝试 / 5 分钟',
10 'auth.login.heading': '登录 Kernel',
11 'auth.login.subheading': '验证身份后进入控制台与运行中的集群。',
8 'auth.login.hint': '同一账号 5 分钟内最多尝试 5 次',
9 'auth.login.heading': '登录 GitDataAI',
10 'auth.login.subheading': '登录后进入仓库、看板与团队协作。',
1211 'auth.login.identifier': '邮箱或用户名',
1312 'auth.login.passphrase': '密码',
14 'auth.login.forgot': '忘记了?',
13 'auth.login.forgot': '忘记密码?',
1514 'auth.login.keepSignedIn': '保持登录状态',
1615 'auth.login.keepSignedInAria': '保持登录状态',
1716 'auth.login.submit': '登录',
18 'auth.login.losingAccess': '无法访问?',
19 'auth.login.recoveryCode': '恢复码',
17 'auth.login.losingAccess': '登录遇到问题?',
2018 'auth.login.resendVerify': '重发验证邮件',
2119 'auth.login.noAccount': '还没有账号?',
22 'auth.login.requestAccess': '申请访问',
20 'auth.login.requestAccess': '创建账号',
2321 'auth.signup.crumb': '创建账号',
24 'auth.signup.hint': '口令策略与年龄门槛由服务端强制校验',
22 'auth.signup.hint': '注册需年满 {age} 周岁 · 密码至少 8 位',
2523 'auth.signup.heading': '创建账号',
26 'auth.signup.subheading': '为团队开通 Kernel 控制台与运行集群的访问权限。',
27 'auth.signup.username': '用户名(可选)',
24 'auth.signup.subheading': '一个账号,进入仓库、看板与团队协作。',
25 'auth.signup.username': '用户名(选填)',
2826 'auth.signup.email': '邮箱',
2927 'auth.signup.password': '密码',
30 'auth.signup.dob': '出生日期(可选)',
31 'auth.signup.dobRequired': '{age}+ 必填',
28 'auth.signup.passwordHint': '至少 8 位,需含大写、小写字母和数字',
29 'auth.signup.dob': '出生日期',
30 'auth.signup.dobRequired': '需年满 {age} 周岁',
3231 'auth.signup.day': '日',
3332 'auth.signup.month': '月',
3433 'auth.signup.year': '年',
@@ -44,61 +43,71 @@
4443 'auth.signup.error.dobIncomplete': '请补全出生日期。',
4544 'auth.signup.error.age': '注册需年满 {age} 周岁。',
4645 'auth.forgot.crumb': '找回访问',
47 'auth.forgot.hint': '恢复链接 15 分钟后失效',
46 'auth.forgot.hint': '重置链接 30 分钟内有效',
4847 'auth.forgot.heading': '忘记密码?',
49 'auth.forgot.subheading': '输入账号邮箱,我们会发送一次性恢复链接,15 分钟后失效。',
48 'auth.forgot.subheading': '输入账号邮箱,我们会发送一次性重置链接,30 分钟内有效。',
5049 'auth.forgot.email': '账号邮箱',
51 'auth.forgot.privacyNote': '我们始终如实反馈发送结果,不会暴露账号是否存在。',
52 'auth.forgot.submit': '发送恢复链接',
50 'auth.forgot.privacyNote': '若邮箱已注册,你会收到重置邮件;出于安全考虑,不会提示账号是否存在。',
51 'auth.forgot.submit': '发送重置链接',
5352 'auth.forgot.remembered': '想起来了?',
5453 'auth.forgot.backToSignIn': '返回登录',
55 'auth.reset.crumb': '新密码',
56 'auth.reset.hint': '其他所有会话将被注销',
54 'auth.reset.crumb': '重置密码',
55 'auth.reset.hint': '修改后所有已登录设备需重新登录',
5756 'auth.reset.heading': '设置新密码',
58 'auth.reset.subheading': '恢复令牌已验证,新密码将按服务端口令策略校验。',
59 'auth.reset.noAddress': '无地址',
60 'auth.reset.tokenValid': '令牌有效',
61 'auth.reset.noToken': '无令牌',
57 'auth.reset.subheading': '链接已验证,请设置新密码。',
58 'auth.reset.subheadingInvalid': '请从邮件中打开重置链接以继续。',
59 'auth.reset.noAddress': '未填写邮箱',
60 'auth.reset.tokenValid': '链接有效',
61 'auth.reset.noToken': '链接无效',
6262 'auth.reset.newPassword': '新密码',
6363 'auth.reset.confirmPassword': '确认新密码',
6464 'auth.reset.strength.strong': '强',
6565 'auth.reset.strength.fair': '中',
6666 'auth.reset.strength.weak': '弱',
6767 'auth.reset.error.mismatch': '两次输入的密码不一致。',
68 'auth.reset.noTokenNote': '请在本设备上打开邮件里的恢复链接,或',
69 'auth.reset.requestNewLink': '申请新链接',
70 'auth.reset.done': '密码已更新,正在跳转到登录页…',
68 'auth.reset.noTokenNote': '请从邮件中打开重置链接,或',
69 'auth.reset.requestNewLink': '重新获取链接',
70 'auth.reset.done': '密码已更新,正在返回登录页…',
7171 'auth.reset.submit': '更新密码',
7272 'auth.verify.crumb': '验证邮箱',
73 'auth.verify.hint': '请检查垃圾邮件目录 · SMTP 投递约 5 秒',
73 'auth.verify.hint': '没收到邮件?请检查垃圾邮件目录',
7474 'auth.verify.heading': '查看你的收件箱',
75 'auth.verify.subheading': '我们已发送验证链接至',
75 'auth.verify.subheading': '验证邮件已发送至',
76 'auth.verify.resetSubheading': '重置邮件已发送至',
7677 'auth.verify.purpose': '用途 · {purpose}',
77 'auth.verify.linkOpened': '链接已在本设备打开,请在下方确认以继续。',
78 'auth.verify.linkExpiry': '链接 24 小时内有效,请在本设备打开以继续。',
79 'auth.verify.verified': '邮箱已验证,正在跳转到登录页…',
80 'auth.verify.resent': '新的验证邮件已发送至 {email}。',
81 'auth.verify.waiting': '等待邮件中的链接 —— 在本设备打开后,点击下方按钮完成确认。',
82 'auth.verify.confirm': '我已验证邮箱',
78 'auth.verify.purpose.signup': '注册',
79 'auth.verify.purpose.password_reset': '重置密码',
80 'auth.verify.purpose.add_email': '添加邮箱',
81 'auth.verify.purpose.reclaim': '账号找回',
82 'auth.verify.linkOpened': '链接已打开,点击下方按钮完成验证。',
83 'auth.verify.linkExpiry': '邮件中的验证码和链接 10 分钟内有效。',
84 'auth.verify.resetBody': '请在 30 分钟内通过邮件中的链接设置新密码。',
85 'auth.verify.verified': '邮箱已验证,正在返回登录页…',
86 'auth.verify.resent': '验证邮件已重新发送至 {email}。',
87 'auth.verify.resetResent': '重置邮件已重新发送至 {email}。',
88 'auth.verify.waiting': '请打开邮件中的验证链接完成验证。',
89 'auth.verify.confirm': '完成验证',
8390 'auth.verify.divider': '没收到?',
8491 'auth.verify.resend': '重发验证邮件',
8592 'auth.verify.resendCountdown': '重发验证邮件({time})',
86 'auth.verify.wrongAddress': '地址有误?',
87 'auth.verify.changeEmail': '修改邮箱',
93 'auth.verify.resetResend': '重发重置邮件',
94 'auth.verify.resetResendCountdown': '重发重置邮件({time})',
95 'auth.verify.wrongAddress': '邮箱填错了?',
96 'auth.verify.changeEmail': '返回修改',
8897 'auth.twofa.crumb': '两步验证',
89 'auth.twofa.hint': '挑战 5 分钟后失效 · 最多 5 次尝试',
90 'auth.twofa.heading': '两步验证码',
91 'auth.twofa.subheading': '请输入已登录账号在验证器应用中的 6 位验证码。',
98 'auth.twofa.hint': '验证码 5 分钟内有效 · 最多尝试 5 次',
99 'auth.twofa.heading': '两步验证',
100 'auth.twofa.subheading': '请输入验证器应用中的 6 位验证码。',
92101 'auth.twofa.recoveryCode': '恢复码',
93102 'auth.twofa.authCode': '验证码',
94103 'auth.twofa.expiresIn': '{time} 后过期',
95 'auth.twofa.noChallengeError': '本设备没有待处理的登录请求,请回到登录页重新开始。',
96 'auth.twofa.noChallengeNote': '没有待处理的验证挑战 —— 请先',
104 'auth.twofa.noChallengeError': '登录请求已过期,请重新登录。',
105 'auth.twofa.noChallengeNote': '登录请求已过期 —— 请先',
97106 'auth.twofa.signIn': '登录',
98107 'auth.twofa.submit': '验证',
99108 'auth.twofa.trouble': '遇到问题?',
100 'auth.twofa.useAuthenticator': '使用验证器应用',
109 'auth.twofa.useAuthenticator': '改用验证器验证码',
101110 'auth.twofa.useRecovery': '使用恢复码',
102 'auth.error.network': '网络异常 —— 请检查连接后重试。',
111 'auth.error.network': '网络连接失败,请检查网络后重试。',
103112 'auth.error.unknown': '出错了,请稍后重试。',
104113} as const
Modified web/src/shared/i18n/messages/zh-CN/space.ts web/src/shared/i18n/messages/zh-CN/space.ts +1 −1 View web/src/shared/i18n/messages/zh-CN/space.ts
@@ -253,7 +253,7 @@
253253 'space.danger.confirmLabel': '输入社区名称 {name} 以确认',
254254 'space.danger.deleting': '删除中…',
255255 'space.danger.confirm': '确认删除',
256 'space.images.sub': '图片以 base64 上传,单个文件不超过 5 MB。',
256 'space.images.sub': '图片按原始字节直传对象存储,单个文件不超过 5 MB。',
257257 'space.images.loadFailed': '读取图片失败,可能没有管理频道的权限。',
258258 'space.images.tooLarge': '图片超过 5 MB,请压缩后再上传。',
259259 'space.images.updated': '{label}已更新。',
Modified web/src/shared/i18n/messages/zh-CN/userSettings.ts web/src/shared/i18n/messages/zh-CN/userSettings.ts +11 −11 View web/src/shared/i18n/messages/zh-CN/userSettings.ts
@@ -45,9 +45,9 @@
4545 'userSettings.security.fillBoth': '请填写当前与新密码。',
4646 'userSettings.security.mismatch': '两次输入的新密码不一致。',
4747 'userSettings.security.totp': '两步验证',
48 'userSettings.security.recoveryCodes': '恢复代码',
49 'userSettings.security.generateCodes': '生成恢复代码',
50 'userSettings.security.codesGenerated': '恢复代码已生成。',
48 'userSettings.security.recoveryCodes': '恢复码',
49 'userSettings.security.generateCodes': '生成恢复码',
50 'userSettings.security.codesGenerated': '恢复码已生成。',
5151 'userSettings.ssh.title': 'SSH 公钥',
5252 'userSettings.ssh.sub': '用于 git over SSH 的克隆与推送。公钥指纹全站唯一,重复登记会被拒绝。',
5353 'userSettings.ssh.duplicate': '该公钥已被登记(指纹全站唯一,可能已属于其他账户)。',
@@ -74,25 +74,25 @@
7474 'userSettings.emails.title': '邮箱',
7575 'userSettings.emails.sub': '登录邮箱、主邮箱与验证状态。',
7676 'userSettings.emails.refresh': '刷新列表',
77 'userSettings.sessions.refresh': '刷新列表', 'userSettings.security.sub': '修改密码、两步验证与恢复代码。',
78 'userSettings.security.totpHeading': '两步验证(TOTP)',
77 'userSettings.sessions.refresh': '刷新列表', 'userSettings.security.sub': '修改密码、两步验证与恢复码。',
78 'userSettings.security.totpHeading': '两步验证',
7979 'userSettings.security.status': '当前状态:',
8080 'userSettings.security.enabled': '已开启',
8181 'userSettings.security.notEnabled': '未开启',
82 'userSettings.security.setupHint': '用验证器 App 添加以下密钥后输入 6 位验证码完成开启。',
82 'userSettings.security.setupHint': '用验证器应用添加以下密钥后输入 6 位验证码完成开启。',
8383 'userSettings.security.enable': '开启两步验证',
8484 'userSettings.security.secret': '密钥',
8585 'userSettings.security.codePlaceholder': '验证器 6 位代码',
8686 'userSettings.security.passwordConfirmPlaceholder': '当前密码(确认用)',
8787 'userSettings.security.verifyAndEnable': '验证并开启',
88 'userSettings.security.enabledNotice': '两步验证已开启,请保存下方恢复代码。',
89 'userSettings.security.codesOnce': '恢复代码(仅显示这一次,请妥善保存)',
90 'userSettings.security.viewCodes': '查看恢复代码',
91 'userSettings.security.codesLoaded': '已加载恢复代码。',
88 'userSettings.security.enabledNotice': '两步验证已开启,请保存下方恢复码。',
89 'userSettings.security.codesOnce': '恢复码(仅显示这一次,请妥善保存)',
90 'userSettings.security.viewCodes': '查看恢复码',
91 'userSettings.security.codesLoaded': '已加载恢复码。',
9292 'userSettings.security.regenerate': '重新生成',
9393 'userSettings.security.regenPasswordPlaceholder': '当前密码(重新生成需要)',
9494 'userSettings.security.code6Placeholder': '6 位验证码',
95 'userSettings.security.disable': '关闭 2FA',
95 'userSettings.security.disable': '关闭两步验证',
9696 'userSettings.ssh.registered': '已登记的公钥({count})',
9797 'userSettings.ssh.none': '还没有添加任何 SSH 公钥。',
9898 'userSettings.ssh.addedAt': '添加于 {when}',
Modified web/index.html web/index.html +1 −1 View web/index.html
@@ -5,7 +5,7 @@
55 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
66 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
77 <meta name="app:region" content="EU-CENTRAL-1" />
8 <meta name="app:min-age" content="16" />
8 <meta name="app:min-age" content="13" />
99 <title>GitDataAI</title>
1010 <link rel="preconnect" href="https://fonts.googleapis.com" />
1111 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.
Modified Cargo.lock Cargo.lock +0 −1 View Cargo.lock
@@ -8462,7 +8462,6 @@
84628462dependencies = [
84638463 "async-trait",
84648464 "axum",
8465 "base64 0.23.1",
84668465 "config",
84678466 "email",
84688467 "export",
Modified openapi.json openapi.json View openapi.json
Diff too large to display.