Skip to content
Clone

feat(space): add endpoint to retrieve space image file and update related routes

ZhenYi committed
parent e8c7e75 commit a0f4acb Browse files

44 files changed

+827 −1517

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

File tree 44
Modified admin/src/pages/login.tsx 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
@@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 View web/src/shared/api/globals.d.ts

Diff too large to display.

Modified web/src/shared/i18n/messages/en/auth.ts 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 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 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 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 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 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 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 View web/openapi.json

Diff too large to display.

Modified Cargo.lock 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 View openapi.json

Diff too large to display.