diff --git a/crates/utopia-server/src/api/google_drive_routes.rs b/crates/utopia-server/src/api/google_drive_routes.rs index 7e44d9955..f8a995f79 100644 --- a/crates/utopia-server/src/api/google_drive_routes.rs +++ b/crates/utopia-server/src/api/google_drive_routes.rs @@ -76,6 +76,36 @@ fn require_connector(source: &Source, user_id: Uuid) -> Result<(), AppError> { }) } +/// 别人连着的来源不能直接重连:一点就会换掉他的 Drive、丢掉他选的文件夹。 +/// 要换人,先断开(断开会问一句,说清是谁的 Drive),再由新的人来连 +fn refuse_someone_elses_connection(source: &Source, user_id: Uuid) -> Result<(), AppError> { + match google_drive::connected_by(&source.config) { + Some(connector) if connector != user_id => Err(AppError::CodedForbidden { + code: "google_drive_connected_by_someone_else", + message: "This source reads someone else's Google Drive; disconnect it first".into(), + }), + _ => Ok(()), + } +} + +/// 同一个 Google 账号是不是还连着别的来源。Google 撤销一枚刷新令牌,撤的是这个人对 +/// 我们这个客户端的整份授权,他别的 Drive 来源会一起失效;还有别的在用就不撤 +pub(super) async fn grant_still_used( + pool: &sqlx::PgPool, + source_id: Uuid, + account_email: &str, +) -> Result { + sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM sources + WHERE kind = 'google_drive' AND id <> $1 + AND config->>'account_email' = $2 AND config ? 'refresh_token')", + ) + .bind(source_id) + .bind(account_email) + .fetch_one(pool) + .await +} + /// 落一次连接流程。总数与这个人的在途数都在同一条插入里判,满了就不插,回 `None` pub(super) async fn record_flow( pool: &sqlx::PgPool, @@ -117,7 +147,8 @@ pub async fn connect( Path((kb_id, source_id)): Path<(Uuid, Uuid)>, ) -> ApiResult> { require_kb(&state, &user, kb_id, Role::Editor).await?; - drive_source(&state, kb_id, source_id).await?; + let source = drive_source(&state, kb_id, source_id).await?; + refuse_someone_elses_connection(&source, user.id)?; let cfg = configured()?; let (flow_state, verifier) = record_flow(&state.pool, user.id, source_id) .await? @@ -176,6 +207,7 @@ pub async fn callback( "session" => "This window is not signed in to Utopia as the person who started the connection. Close it and start again from the source settings.", "expired" => "This connection attempt has expired. Close this window and start again.", "scope" => "Google did not grant read access to Drive. Close this window, connect again and allow it.", + "taken" => "Someone else connected this source while you were on Google's page. Close this window.", _ => "Google could not be reached or refused the request. Close this window and try again.", }, ) @@ -225,6 +257,13 @@ pub(super) async fn finish( "user is no longer an editor of the knowledge base".to_string(), ) })?; + // 同意页上的这几分钟里,别人可能先连上了 + refuse_someone_elses_connection(&source, user.id).map_err(|_| { + ( + "taken", + "someone else connected this source in the meantime".to_string(), + ) + })?; let cfg = google_drive::oauth_config().ok_or(("unavailable", "not configured".to_string()))?; let grant = google_drive::exchange_code(&cfg, &code, &verifier) @@ -241,12 +280,6 @@ pub(super) async fn finish( ) })?; - // 换了一个人连:上一个人选的文件夹是照他的 Drive 选的,不留给新的人 - let drop_folders: &[&str] = if google_drive::connected_by(&source.config) == Some(user.id) { - &[] - } else { - &["folders"] - }; let grant_keys = json!({ "refresh_token": grant.refresh_token, "account_email": grant.account_email, @@ -254,7 +287,7 @@ pub(super) async fn finish( "connected_at": chrono::Utc::now().to_rfc3339(), "connected_by": user.id, }); - utopia_store::sources::patch_config(&state.pool, source_id, &grant_keys, drop_folders) + utopia_store::sources::patch_config(&state.pool, source_id, &grant_keys, &[]) .await .map_err(|e| ("unavailable", format!("saving the grant: {e}")))?; state.emit_source(source.kb_id); @@ -272,7 +305,8 @@ pub(super) async fn finish( Ok(()) } -/// 断开:删掉刷新令牌,并请 Google 把它作废。作废失败不拦着——库里已经没有它了 +/// 断开:删掉刷新令牌,并请 Google 把它作废——除非同一个账号还连着别的来源 +///([`grant_still_used`]),或者不知道是哪个账号。作废失败不拦着:库里已经没有它了 pub async fn disconnect( State(state): State, AuthUser(user): AuthUser, @@ -281,17 +315,30 @@ pub async fn disconnect( require_kb(&state, &user, kb_id, Role::Editor).await?; let source = drive_source(&state, kb_id, source_id).await?; let token = source.config["refresh_token"].as_str().map(String::from); + let account = source.config["account_email"].as_str().map(String::from); utopia_store::sources::patch_config(&state.pool, source_id, &json!({}), CONNECTION_KEYS) .await?; - if let Some(token) = token { + let revoke = match (token, account) { + (Some(token), Some(account)) + if !grant_still_used(&state.pool, source_id, &account).await? => + { + Some(token) + } + _ => None, + }; + if let Some(token) = revoke { let revoked = reqwest::Client::new() .post("https://oauth2.googleapis.com/revoke") .form(&[("token", token.as_str())]) .timeout(std::time::Duration::from_secs(10)) .send() .await; - if let Err(e) = revoked { - tracing::warn!(error = %e, "Google 刷新令牌作废失败,库里已删除"); + match revoked { + Ok(resp) if resp.status().is_success() => {} + Ok(resp) => { + tracing::warn!(status = %resp.status(), "Google 拒绝作废刷新令牌,库里已删除") + } + Err(e) => tracing::warn!(error = %e, "Google 刷新令牌作废失败,库里已删除"), } } state.emit_source(kb_id); diff --git a/crates/utopia-server/src/api/google_drive_routes_tests.rs b/crates/utopia-server/src/api/google_drive_routes_tests.rs index edf4f1737..29ab75a1b 100644 --- a/crates/utopia-server/src/api/google_drive_routes_tests.rs +++ b/crates/utopia-server/src/api/google_drive_routes_tests.rs @@ -118,7 +118,9 @@ impl Fixture { fn denied_code(result: ApiResult) -> Option<&'static str> { match result { - Err(ApiErr(AppError::CodedForbidden { code, .. })) => Some(code), + Err(ApiErr(AppError::CodedForbidden { code, .. } | AppError::Invalid { code, .. })) => { + Some(code) + } _ => None, } } @@ -291,3 +293,91 @@ async fn a_sync_stops_once_the_connected_person_is_no_longer_an_editor() -> anyh ); Ok(()) } + +#[tokio::test] +async fn a_colleague_cannot_reconnect_the_connected_persons_drive() -> anyhow::Result<()> { + let Some(fx) = Fixture::new().await? else { + return Ok(()); + }; + let started = connect( + State(fx.state.clone()), + AuthUser(fx.colleague.clone()), + Path((fx.kb, fx.source)), + ) + .await; + assert_eq!( + denied_code(started), + Some("google_drive_connected_by_someone_else") + ); + Ok(()) +} + +#[tokio::test] +async fn a_callback_after_someone_else_connected_is_refused() -> anyhow::Result<()> { + let Some(fx) = Fixture::new().await? else { + return Ok(()); + }; + let (flow_state, _) = record_flow(&fx.pool, fx.colleague.id, fx.source) + .await? + .expect("room for a flow"); + assert_eq!(fx.callback(Some(&fx.colleague), &flow_state).await, "taken"); + Ok(()) +} + +#[tokio::test] +async fn a_grant_is_kept_while_another_source_uses_the_same_account() -> anyhow::Result<()> { + let Some(fx) = Fixture::new().await? else { + return Ok(()); + }; + let account = format!("{}@drive.test", Uuid::now_v7()); + let other = utopia_store::sources::create( + &fx.pool, + fx.kb, + "google_drive", + "Other Drive", + &json!({}), + None, + None, + None, + ) + .await? + .id; + for id in [fx.source, other] { + let grant = json!({ "refresh_token": "1//grant", "account_email": account }); + utopia_store::sources::patch_config(&fx.pool, id, &grant, &[]).await?; + } + let while_other_connected = grant_still_used(&fx.pool, fx.source, &account).await?; + utopia_store::sources::patch_config(&fx.pool, other, &json!({}), CONNECTION_KEYS).await?; + let after_other_disconnected = grant_still_used(&fx.pool, fx.source, &account).await?; + assert_eq!( + (while_other_connected, after_other_disconnected), + (true, false) + ); + Ok(()) +} + +#[tokio::test] +async fn a_drive_source_cannot_be_created_without_an_oauth_client() -> anyhow::Result<()> { + let Some(fx) = Fixture::new().await? else { + return Ok(()); + }; + let created = super::super::sources_routes::create( + State(fx.state.clone()), + crate::auth::SessionOrToken { + user: fx.connector.clone(), + pat: None, + }, + Path(fx.kb), + Json(super::super::sources_routes::CreateBody { + kind: "google_drive".into(), + name: "Drive".into(), + config: json!({}), + icon: None, + sync_interval_minutes: None, + sync_cron: None, + }), + ) + .await; + assert_eq!(denied_code(created), Some("google_drive_not_configured")); + Ok(()) +} diff --git a/crates/utopia-server/src/api/sources_routes.rs b/crates/utopia-server/src/api/sources_routes.rs index 14738966b..8285b2d9b 100644 --- a/crates/utopia-server/src/api/sources_routes.rs +++ b/crates/utopia-server/src/api/sources_routes.rs @@ -109,11 +109,16 @@ pub async fn create( let user = caller.user; require_kb(&state, &user, kb_id, Role::Editor).await?; validate_rss_config(&body.kind, &body.config)?; - let config = if config_is_drive_owned(&body.kind) { - json!({}) - } else { - body.config - }; + let drive = config_is_drive_owned(&body.kind); + // 没配 OAuth 客户端的部署连不上 Google,建出来也只会一直失败(0074 决定 2) + if drive && crate::google_drive::oauth_config().is_none() { + return Err(utopia_core::AppError::invalid( + "google_drive_not_configured", + "Google Drive is not configured on this server", + ) + .into()); + } + let config = if drive { json!({}) } else { body.config }; let source = utopia_store::sources::create( &state.pool, kb_id, diff --git a/crates/utopia-server/src/google_drive.rs b/crates/utopia-server/src/google_drive.rs index 5c94cc88f..07adb541e 100644 --- a/crates/utopia-server/src/google_drive.rs +++ b/crates/utopia-server/src/google_drive.rs @@ -75,6 +75,7 @@ const MAX_RETRIES: u32 = 3; const JSON_LIMIT: usize = 4 * 1024 * 1024; /// 部署级的 OAuth 客户端。三项都得有,缺一项就当没配 +#[derive(Clone)] pub struct OAuthConfig { pub client_id: String, pub client_secret: String, @@ -350,11 +351,19 @@ fn export_as(mime: &str) -> Option<(&'static str, &'static str)> { /// 拿着一个访问令牌的 Drive 客户端 pub struct Drive { http: reqwest::Client, - token: String, + token: std::sync::Mutex, + /// 访问令牌一小时就过期,一轮同步可以更长:遇到 401 凭刷新令牌换一枚新的,再试一次 + renewal: Option, // 私有地址入口让 HTTP 测试走真实的遍历逻辑,不增加用户配置 api_root: String, } +struct Renewal { + cfg: OAuthConfig, + refresh_token: String, + token_url: String, +} + impl Drive { /// 用刷新令牌换访问令牌,得到一个能用的客户端 pub async fn connect(cfg: &OAuthConfig, refresh_token: &str) -> anyhow::Result { @@ -362,12 +371,18 @@ impl Drive { let token = access_token_at(&http, TOKEN_URL, cfg, refresh_token).await?; Ok(Self { http, - token, + token: std::sync::Mutex::new(token), + renewal: Some(Renewal { + cfg: cfg.clone(), + refresh_token: refresh_token.into(), + token_url: TOKEN_URL.into(), + }), api_root: API_ROOT.into(), }) } - /// 发一次 GET。429 与 5xx 退避重试;其余非 2xx 带上 Google 自己的 message 报错, + /// 发一次 GET。429 与 5xx 退避重试;401 换一枚访问令牌再试一次(只一次:换来的也被拒, + /// 就是授权本身出了问题);其余非 2xx 带上 Google 自己的 message 报错, /// 状态码与 `reason` 留在错误里给调用方分辨(404 = 打不开) async fn get( &self, @@ -378,15 +393,24 @@ impl Drive { let url = reqwest::Url::parse_with_params(url, query) .with_context(|| format!("google drive {what}: building the URL"))?; let mut attempt = 0u32; + let mut renewed = false; loop { + let token = self.token.lock().expect("token lock").clone(); let resp = self .http .get(url.clone()) - .bearer_auth(&self.token) + .bearer_auth(&token) .send() .await .with_context(|| format!("google drive {what}"))?; let status = resp.status(); + if let (StatusCode::UNAUTHORIZED, false, Some(r)) = (status, renewed, &self.renewal) { + renewed = true; + let fresh = + access_token_at(&self.http, &r.token_url, &r.cfg, &r.refresh_token).await?; + *self.token.lock().expect("token lock") = fresh; + continue; + } if (status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) && attempt < MAX_RETRIES { diff --git a/crates/utopia-server/src/google_drive_tests.rs b/crates/utopia-server/src/google_drive_tests.rs index dcf08401b..ebfbb08c6 100644 --- a/crates/utopia-server/src/google_drive_tests.rs +++ b/crates/utopia-server/src/google_drive_tests.rs @@ -3,14 +3,15 @@ //! 给了令牌才跑。 use super::*; use serde_json::json; -use wiremock::matchers::{body_string_contains, method, path, query_param}; +use wiremock::matchers::{body_string_contains, header, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn drive(server: &MockServer) -> Drive { Drive { // 本地 mock 不走开发机的系统代理 http: reqwest::Client::builder().no_proxy().build().unwrap(), - token: "ya29.test".into(), + token: std::sync::Mutex::new("ya29.test".into()), + renewal: None, api_root: server.uri(), } } @@ -390,6 +391,54 @@ async fn a_revoked_grant_says_reconnect() { assert!(e.to_string().contains("reconnect"), "{e}"); } +/// 一轮同步超过一小时,访问令牌就过期了:401 换一枚新的再试,不把文件报成「读不出来」 +#[tokio::test] +async fn an_expired_access_token_is_renewed_once_mid_sync() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/files/f1")) + .and(header("authorization", "Bearer ya29.expired")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains("refresh_token=1%2F%2Fgrant")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"access_token": "ya29.fresh"})), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/files/f1")) + .and(header("authorization", "Bearer ya29.fresh")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4 body".to_vec())) + .mount(&server) + .await; + let drive = Drive { + http: reqwest::Client::builder().no_proxy().build()?, + token: std::sync::Mutex::new("ya29.expired".into()), + renewal: Some(Renewal { + cfg: cfg(), + refresh_token: "1//grant".into(), + token_url: format!("{}/token", server.uri()), + }), + api_root: server.uri(), + }; + let first = drive + .get("download", &format!("{}/files/f1", server.uri()), &[]) + .await?; + let second = drive + .get("download", &format!("{}/files/f1", server.uri()), &[]) + .await?; + assert_eq!( + (first.bytes().await?.as_ref(), second.status()), + (b"%PDF-1.4 body".as_ref(), StatusCode::OK) + ); + Ok(()) +} + /// ID token:签给别的 client 的、别的 issuer 的不收;没验证过的邮箱不显示 #[test] fn an_id_token_must_be_googles_and_ours() { diff --git a/docs/decisions/0074-a-google-drive-source-is-connected-by-a-person.md b/docs/decisions/0074-a-google-drive-source-is-connected-by-a-person.md index 561fd8d1c..3e141ab7f 100644 --- a/docs/decisions/0074-a-google-drive-source-is-connected-by-a-person.md +++ b/docs/decisions/0074-a-google-drive-source-is-connected-by-a-person.md @@ -1,7 +1,7 @@ # 0074 · A Google Drive source is connected by a person - **Status**: Implemented · 2026-10-23 · migration 0120 · tried by hand against a real Drive; the test fixtures are written from the documentation, not recorded (0013 decision 4) · open: revision history, change-feed sync -- **Written**: 2026-10-14, revised 2026-10-23 after review: the connecting person owns the selection, the cap is on downloads (conventions in the [README](README.md)) +- **Written**: 2026-10-14, revised 2026-10-23 after two reviews: the connecting person owns the selection and the connection, the cap is on downloads, a revoke spares a grant still in use (conventions in the [README](README.md)) - **Related**: [0013](0013-a-source-should-hand-over-its-history.md) (what a source must pass, fixtures from real responses); [0014](0014-identity-from-the-person-scope-from-the-token.md) (identity comes from the person); migration `0056` (`oidc_flows`, the shape the connection flow copies); [#246](https://github.com/deeplethe/utopia/issues/246) (a credential never reaches a viewer) ## Problem @@ -14,13 +14,13 @@ Every existing connector is configured with a key an administrator pastes in. Dr 1. **A person connects the source with OAuth, and it reads Drive as them.** An editor of the knowledge base opens the source's settings and clicks Connect. Google's consent screen asks for `drive.readonly` plus `openid email`, and the refresh token is stored in the source's config under `refresh_token`, which is in `SOURCE_SECRET_KEYS`: sealed at rest, never in a listing, never in a response (#246). The browser never holds a Google token. Even the folder browser asks the server, which reads Drive with the stored grant. `drive.file` was not enough: it covers only files the app created or the person picked one by one, and picking a folder does not include what is inside it. -2. **The OAuth client belongs to the deployment.** `UTOPIA_GOOGLE_DRIVE_CLIENT_ID`, `_CLIENT_SECRET` and `_REDIRECT_URI` name a Google "web application" client the operator registers. When any of the three is missing, `GET /google-drive/status` says so, the kind cannot be created from the dialog, and a sync fails with that sentence rather than a 401. This client is separate from the SSO client of 0056. The two ask for different scopes, and one deployment may sign people in elsewhere and still read Drive. +2. **The OAuth client belongs to the deployment.** `UTOPIA_GOOGLE_DRIVE_CLIENT_ID`, `_CLIENT_SECRET` and `_REDIRECT_URI` name a Google "web application" client the operator registers. When any of the three is missing, `GET /google-drive/status` says so, creating the kind is refused (`google_drive_not_configured`), and a sync fails with that sentence rather than a 401. This client is separate from the SSO client of 0056. The two ask for different scopes, and one deployment may sign people in elsewhere and still read Drive. -3. **The flow is bound to the person who started it.** `POST …/google-drive/connect` records a single-use `state`, a PKCE verifier, the user and the source in `google_drive_flows` (ten minutes, at most 5 pending per person and 1000 in all, both checked in the insert itself), and returns Google's URL. The callback deletes the row before using it, requires the browser's session to be that same user, and checks again that the user is still an editor. Without the session check, someone could start a flow for their own source and get another person to click through consent, and the other person's Drive would be connected to their source. `prompt=consent` and `access_type=offline` are always sent, so a reconnect after revocation gets a new refresh token. +3. **The flow is bound to the person who started it.** `POST …/google-drive/connect` records a single-use `state`, a PKCE verifier, the user and the source in `google_drive_flows` (ten minutes, at most 5 pending per person and 1000 in all, both checked in the insert itself), and returns Google's URL. The callback deletes the row before using it, requires the browser's session to be that same user, and checks again that the user is still an editor. Without the session check, someone could start a flow for their own source and get another person to click through consent, and the other person's Drive would be connected to their source. `prompt=consent` and `access_type=offline` are always sent, so a reconnect after revocation gets a new refresh token. The access token a sync works with lasts an hour and a run can last longer, so a 401 mid-run gets a new access token from the refresh token and the request is tried once more. 4. **The ID token names the account, and that is all it does.** `openid email` exists so the settings can say "connected as bruno@…". The ID token comes straight from Google's token endpoint over TLS, so issuer and audience are checked and the signature is not (OIDC Core 3.1.3.7). An unverified email is not shown. The email never identifies a Utopia account and never links one; that stays as 0056 decided. `connected_at` in the config is what the interface reads as "connected", because the email may legitimately be absent. -5. **The person who connected chooses the folders, and subfolders come with them.** The callback records `connected_by`. Only that person may browse (`GET …/google-drive/folders`) and save a selection (`PUT …/google-drive/folders`); another editor gets 403. Everyone in the base can read what is synced, so a colleague who could browse would be choosing what leaves someone else's Drive. For the same reason a Drive source's config is written only by its connect, select and disconnect routes: the general source create and update leave it alone. When someone else reconnects, the previous selection is dropped, and disconnecting removes it with the grant. `config.folders` is `[{id, name}]`. The name is only for the interface. The browser lists My Drive, Shared with me and every shared drive. Saving the selection queues a sync at once. Ids are checked against `[A-Za-z0-9_-]` before they go into a Drive query, which is simpler to trust than escaping. Each of these routes changes only its own keys in one statement (`patch_config`), so a selection saved during a callback cannot drop the token the callback just wrote. +5. **The person who connected chooses the folders, and subfolders come with them.** The callback records `connected_by`. Only that person may browse (`GET …/google-drive/folders`) and save a selection (`PUT …/google-drive/folders`); another editor gets 403. Everyone in the base can read what is synced, so a colleague who could browse would be choosing what leaves someone else's Drive. For the same reason a Drive source's config is written only by its connect, select and disconnect routes: the general source create and update leave it alone. Only the person who connected may reconnect: anyone else gets 403 from `connect`, and a callback that finds someone else connected in the meantime is refused. Otherwise one click by a colleague would replace that person's Drive with their own. To change who reads, an editor disconnects first, which removes the selection with the grant (decision 11), and then connects. `config.folders` is `[{id, name}]`. The name is only for the interface. The browser lists My Drive, Shared with me and every shared drive. Saving the selection queues a sync at once. Ids are checked against `[A-Za-z0-9_-]` before they go into a Drive query, which is simpler to trust than escaping. Each of these routes changes only its own keys in one statement (`patch_config`), so a selection saved during a callback cannot drop the token the callback just wrote. 6. **The grant reads only while its person is an editor.** A sync first checks that `connected_by` is still an active editor of the base. When they have left, been made a viewer or been deactivated, the run fails with "reconnect it in the source settings" and reads nothing. Their Google grant would otherwise keep pulling their files into a base they no longer belong to. @@ -32,7 +32,7 @@ Every existing connector is configured with a key an administrator pastes in. Dr 10. **Retraction follows a complete walk only.** Files not seen are marked "not in source" (`reconcile_missing`) only when every selected folder opened and the listing was complete. The download cap does not stop it, because the listing still saw every file. A folder that returns 404, because it was deleted or is no longer shared with the connected person, fails the run by name and marks nothing. Not being able to look is not the same as finding it empty. A file that cannot be read (Google refuses exports over 10 MB) keeps its stored document, the rest of the run continues, and the run is reported as failed with the names, as Notion does. -11. **Disconnecting forgets the grant and asks Google to revoke it.** `DELETE …/google-drive/connection` removes the token from the config first and then calls Google's revoke endpoint. If the revoke fails, that is logged, and the token is already gone from Utopia. Connecting and disconnecting are audited with the account email and never the token. +11. **Disconnecting forgets the grant, and revokes it at Google when nothing else uses it.** `DELETE …/google-drive/connection` removes the token and the selection from the config first. Revoking a refresh token at Google ends the person's whole grant to our OAuth client, every token issued under it, so it would also stop their other Drive sources. The revoke is therefore sent only when no other Google Drive source holds a token for the same account (`account_email`). When the account is unknown because the email was unverified, nothing is revoked: the token is gone from Utopia, and the person can still remove the grant in their Google account. A failed revoke, by network or by a non-2xx status, is logged and does not undo the disconnect. Any editor may disconnect, since stopping a read never exposes anything. Connecting and disconnecting are audited with the account email and never the token. ## Dead ends diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 0c93c135d..919a3879c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -129,6 +129,8 @@ export const en = { "The Memory source is part of the knowledge base and stays.", google_drive_not_configured: "Google Drive is not set up on this server. An administrator sets UTOPIA_GOOGLE_DRIVE_CLIENT_ID, _CLIENT_SECRET and _REDIRECT_URI.", + google_drive_connected_by_someone_else: + "This source reads someone else's Google Drive. Disconnect it first, then connect your own.", google_drive_not_connector: "Only the person who connected this Google Drive can choose its folders.", google_drive_not_connected: "Connect a Google account first.", @@ -805,6 +807,12 @@ export const en = { remove: "Remove", saveFolders: "Save folders and sync", saved: "Folders saved. A sync has started.", + disconnectConfirm: (account: string, mine: boolean) => + mine + ? `Stop reading your Google Drive (${account})? Its access and the chosen folders are removed here, and connecting again starts from scratch.` + : `Stop reading ${account}'s Google Drive? Someone else connected it, and only they chose its folders. Its access and those folders are removed here.`, + unnamedAccount: "the connected account", + cancel: "Cancel", disconnected: "Google Drive disconnected.", }, tokenField: "GitHub token (optional, never shown again)", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1c83aa7d2..78b09609d 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -118,6 +118,7 @@ export const zh: Strings = { memory_source_permanent: "「记忆」是知识库自带的来源,会一直在。", google_drive_not_configured: "这台服务器没有配置 Google Drive。需要管理员设置 UTOPIA_GOOGLE_DRIVE_CLIENT_ID、_CLIENT_SECRET 与 _REDIRECT_URI。", + google_drive_connected_by_someone_else: "这个来源读的是别人的 Google Drive。先断开,再连你自己的。", google_drive_not_connector: "只有连上这个 Google Drive 的人能选它的文件夹。", google_drive_not_connected: "先连接一个 Google 账号。", google_drive_unavailable: "读不了 Google Drive。", @@ -735,6 +736,12 @@ export const zh: Strings = { remove: "移除", saveFolders: "保存并同步", saved: "文件夹已保存,同步已开始。", + disconnectConfirm: (account: string, mine: boolean) => + mine + ? `停止读取你的 Google Drive(${account})?这里会删掉它的授权和选好的文件夹,再连接要从头选。` + : `停止读取 ${account} 的 Google Drive?这是别人连上的,文件夹也是他选的。这里会删掉它的授权和那些文件夹。`, + unnamedAccount: "已连接的账号", + cancel: "取消", disconnected: "Google Drive 已断开。", }, tokenField: "GitHub 令牌(可选,不再显示)", diff --git a/web/src/pages/GoogleDrivePanel.tsx b/web/src/pages/GoogleDrivePanel.tsx index 5fa14bef6..2d5511c57 100644 --- a/web/src/pages/GoogleDrivePanel.tsx +++ b/web/src/pages/GoogleDrivePanel.tsx @@ -13,7 +13,7 @@ import { api, type SourceView } from "../api"; import { fetchMe } from "../authSession"; import { S } from "../i18n"; import { toast } from "../toast"; -import { Button, Checkbox, Chip, IconButton, LinkButton } from "../ui"; +import { Button, Checkbox, Chip, DangerConfirm, IconButton, LinkButton } from "../ui"; type Entry = { id: string; name: string }; @@ -35,6 +35,8 @@ export function GoogleDrivePanel({ kbId, source }: { kbId: string; source: Sourc // 浏览路径:空 = 第一层(我的云端硬盘、与我共享、共享云端硬盘) const [path, setPath] = useState([]); const [waiting, setWaiting] = useState(false); + // 断开会丢掉连接者的令牌与文件夹:先问一句,说清断开的是谁的 Drive + const [confirmingDisconnect, setConfirmingDisconnect] = useState(false); const popup = useRef(null); const startedFrom = useRef(null); @@ -92,6 +94,7 @@ export function GoogleDrivePanel({ kbId, source }: { kbId: string; source: Sourc const disconnect = useMutation({ mutationFn: () => api.googleDriveDisconnect(kbId, source.id), onSuccess: () => { + setConfirmingDisconnect(false); toast.success(G.disconnected); queryClient.invalidateQueries({ queryKey: ["sources", kbId] }); }, @@ -141,22 +144,37 @@ export function GoogleDrivePanel({ kbId, source }: { kbId: string; source: Sourc variant="secondary" size="sm" disabled={disconnect.isPending} - onClick={() => disconnect.mutate()} + onClick={() => setConfirmingDisconnect(true)} > {G.disconnect} )} - + {/* 别人连着的只能先断开再连:重连会换掉他的 Drive,服务器也会拒绝 */} + {(!connectedAt || mine) && ( + + )} + {confirmingDisconnect && ( + disconnect.mutate()} + onCancel={() => setConfirmingDisconnect(false)} + /> + )} + {connectedAt && !mine && ( <>
{G.folders}