mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
feat(tui): gate /dispatch on Codewhale membership and hide the sandbox brand
Daytona is the provider Codewhale operates, not a user-facing product: membership (the Operate door) entitles cloud agents, and users never see or set a provider key. The fail-closed path now checks the account session (AccountSessionStore) and tells the user to sign in with `codewhale login` instead of exporting DAYTONA_API_KEY; status/notes/ locale copy say "Codewhale cloud agent" everywhere the brand used to leak. Credential discovery is unchanged for the service-side path, and tests now pin the no-brand invariant. Signed-off-by: Hunter Bown <[email protected]>
This commit is contained in:
1 parent
0791d83554
commit
4081785fbe
17 files changed
+63
-66
No files matched your search
@@ -397,7 +397,7 @@
|
||||
"CmdLspDescription": "Activa o desactiva els diagnòstics LSP",
|
||||
"CmdShareDescription": "Exporta la sessió actual com a URL web compartible",
|
||||
"CmdJobsDescription": "Inspecciona i controla els treballs Bash en segon pla",
|
||||
"CmdDispatchDescription": "Envia un agent cloud Daytona per crear una branca i obrir un PR",
|
||||
"CmdDispatchDescription": "Envia un agent cloud de Codewhale per crear una branca i obrir un PR",
|
||||
"CmdLinksDescription": "Mostra enllaços de Codewhale, la comunitat i els proveïdors",
|
||||
"CmdLoadDescription": "Carrega una sessió des d'un fitxer",
|
||||
"CmdLogoutDescription": "Esborra la clau d'API i torna a la configuració",
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
"CmdLspDescription": "LSP-Diagnosen ein- oder ausschalten",
|
||||
"CmdShareDescription": "Aktuelle Sitzung als teilbare Web-URL exportieren",
|
||||
"CmdJobsDescription": "Bash-Hintergrundjobs ansehen und steuern",
|
||||
"CmdDispatchDescription": "Daytona-Cloud-Agenten senden, um einen Branch und PR zu öffnen",
|
||||
"CmdDispatchDescription": "Codewhale-Cloud-Agenten senden, um einen Branch und PR zu öffnen",
|
||||
"CmdLinksDescription": "Codewhale-, Community- und Provider-Links anzeigen",
|
||||
"CmdLoadDescription": "Sitzung aus Datei laden",
|
||||
"CmdLogoutDescription": "API-Schlüssel löschen und zum Setup zurückkehren",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"CmdLspDescription": "Toggle LSP diagnostics on or off",
|
||||
"CmdShareDescription": "Export current session as a shareable web URL",
|
||||
"CmdJobsDescription": "Inspect and control background Bash jobs",
|
||||
"CmdDispatchDescription": "Send a Daytona cloud agent to raise a branch and open a PR",
|
||||
"CmdDispatchDescription": "Send a Codewhale cloud agent to raise a branch and open a PR",
|
||||
"CmdLinksDescription": "Show Codewhale, community, and provider links",
|
||||
"CmdLoadDescription": "Load session from file",
|
||||
"CmdLogoutDescription": "Clear API key and return to setup",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"CmdLspDescription": "Alternar diagnóstico LSP encendido o apagado",
|
||||
"CmdShareDescription": "Exportar la sesión actual como una URL web compartible",
|
||||
"CmdJobsDescription": "Inspeccionar y controlar trabajos de shell en segundo plano",
|
||||
"CmdDispatchDescription": "Enviar un agente cloud Daytona para crear una rama y abrir un PR",
|
||||
"CmdDispatchDescription": "Enviar un agente cloud de Codewhale para crear una rama y abrir un PR",
|
||||
"CmdLinksDescription": "Mostrar enlaces de Codewhale, la comunidad y los proveedores",
|
||||
"CmdLoadDescription": "Cargar la sesión desde un archivo",
|
||||
"CmdLogoutDescription": "Limpiar la clave de API y volver a la configuración",
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
"CmdLspDescription": "Activer ou désactiver les diagnostics LSP",
|
||||
"CmdShareDescription": "Exporter la session en cours en URL web partageable",
|
||||
"CmdJobsDescription": "Inspecter et contrôler les jobs Bash en arrière-plan",
|
||||
"CmdDispatchDescription": "Envoyer un agent cloud Daytona pour créer une branche et ouvrir une PR",
|
||||
"CmdDispatchDescription": "Envoyer un agent cloud Codewhale pour créer une branche et ouvrir une PR",
|
||||
"CmdLinksDescription": "Afficher les liens Codewhale, communauté et fournisseurs",
|
||||
"CmdLoadDescription": "Charger une session depuis un fichier",
|
||||
"CmdLogoutDescription": "Effacer la clé API et revenir à la configuration",
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
"CmdLspDescription": "LSP डायग्नोस्टिक्स चालू/बंद करें",
|
||||
"CmdShareDescription": "वर्तमान सत्र को साझा करने योग्य वेब URL के रूप में एक्सपोर्ट करें",
|
||||
"CmdJobsDescription": "बैकग्राउंड Bash जॉब देखें और नियंत्रित करें",
|
||||
"CmdDispatchDescription": "शाखा बनाने और PR खोलने के लिए Daytona क्लाउड एजेंट भेजें",
|
||||
"CmdDispatchDescription": "शाखा बनाने और PR खोलने के लिए Codewhale क्लाउड एजेंट भेजें",
|
||||
"CmdLinksDescription": "Codewhale, समुदाय और प्रोवाइडर लिंक दिखाएँ",
|
||||
"CmdLoadDescription": "फ़ाइल से सत्र लोड करें",
|
||||
"CmdLogoutDescription": "API कुंजी साफ़ करें और सेटअप पर लौटें",
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
"CmdLspDescription": "Aktifkan atau nonaktifkan diagnostik LSP",
|
||||
"CmdShareDescription": "Ekspor sesi saat ini sebagai URL web yang dapat dibagikan",
|
||||
"CmdJobsDescription": "Periksa dan kendalikan job Bash latar belakang",
|
||||
"CmdDispatchDescription": "Kirim agen cloud Daytona untuk membuat branch dan membuka PR",
|
||||
"CmdDispatchDescription": "Kirim agen cloud Codewhale untuk membuat branch dan membuka PR",
|
||||
"CmdLinksDescription": "Tampilkan tautan Codewhale, komunitas, dan penyedia",
|
||||
"CmdLoadDescription": "Muat sesi dari file",
|
||||
"CmdLogoutDescription": "Hapus kunci API dan kembali ke penyiapan",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"CmdLspDescription": "LSP 診断のオン・オフを切り替え",
|
||||
"CmdShareDescription": "現在のセッションを共有可能な Web URL としてエクスポート",
|
||||
"CmdJobsDescription": "バックグラウンドのシェルジョブを確認・制御",
|
||||
"CmdDispatchDescription": "Daytona クラウドエージェントを送り、ブランチと PR を作成",
|
||||
"CmdDispatchDescription": "Codewhale クラウドエージェントを送り、ブランチと PR を作成",
|
||||
"CmdLinksDescription": "Codewhale、コミュニティ、プロバイダーのリンクを表示",
|
||||
"CmdLoadDescription": "ファイルからセッションを読み込み",
|
||||
"CmdLogoutDescription": "API キーを消去してセットアップに戻る",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"CmdLspDescription": "LSP 진단을 켜거나 끕니다",
|
||||
"CmdShareDescription": "현재 세션을 공유 가능한 웹 URL로 내보냅니다",
|
||||
"CmdJobsDescription": "백그라운드 Bash 작업을 확인하고 제어합니다",
|
||||
"CmdDispatchDescription": "Daytona 클라우드 에이전트를 보내 브랜치를 만들고 PR을 엽니다",
|
||||
"CmdDispatchDescription": "Codewhale 클라우드 에이전트를 보내 브랜치를 만들고 PR을 엽니다",
|
||||
"CmdLinksDescription": "Codewhale, 커뮤니티 및 프로바이더 링크를 표시합니다",
|
||||
"CmdLoadDescription": "파일에서 세션을 불러옵니다",
|
||||
"CmdLogoutDescription": "API 키를 지우고 설정 화면으로 돌아갑니다",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"CmdLspDescription": "Alternar diagnóstico LSP ligado ou desligado",
|
||||
"CmdShareDescription": "Exportar a sessão atual como uma URL web compartilhável",
|
||||
"CmdJobsDescription": "Inspecionar e controlar jobs de shell em segundo plano",
|
||||
"CmdDispatchDescription": "Enviar um agente cloud Daytona para criar um branch e abrir um PR",
|
||||
"CmdDispatchDescription": "Enviar um agente cloud da Codewhale para criar um branch e abrir um PR",
|
||||
"CmdLinksDescription": "Exibir links do Codewhale, da comunidade e dos provedores",
|
||||
"CmdLoadDescription": "Carregar a sessão de um arquivo",
|
||||
"CmdLogoutDescription": "Limpar a chave de API e voltar à configuração",
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
"CmdLspDescription": "Включить или выключить диагностику LSP",
|
||||
"CmdShareDescription": "Экспортировать текущую сессию как веб-ссылку для общего доступа",
|
||||
"CmdJobsDescription": "Просмотр и управление фоновыми заданиями Bash",
|
||||
"CmdDispatchDescription": "Отправить облачного агента Daytona, чтобы создать ветку и открыть PR",
|
||||
"CmdDispatchDescription": "Отправить облачного агента Codewhale, чтобы создать ветку и открыть PR",
|
||||
"CmdLinksDescription": "Показать ссылки на Codewhale, сообщество и провайдеров",
|
||||
"CmdLoadDescription": "Загрузить сессию из файла",
|
||||
"CmdLogoutDescription": "Удалить API-ключ и вернуться к настройке",
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
"CmdLspDescription": "Увімкнути або вимкнути діагностику LSP",
|
||||
"CmdShareDescription": "Експортувати поточний сеанс як веб-URL для поширення",
|
||||
"CmdJobsDescription": "Переглянути й керувати фоновими завданнями Bash",
|
||||
"CmdDispatchDescription": "Надіслати хмарного агента Daytona, щоб створити гілку й відкрити PR",
|
||||
"CmdDispatchDescription": "Надіслати хмарного агента Codewhale, щоб створити гілку й відкрити PR",
|
||||
"CmdLinksDescription": "Показати посилання Codewhale, спільноти та провайдерів",
|
||||
"CmdLoadDescription": "Завантажити сеанс з файлу",
|
||||
"CmdLogoutDescription": "Очистити ключ API та повернутися до налаштування",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"CmdLspDescription": "Bật hoặc tắt tính năng chẩn đoán LSP",
|
||||
"CmdShareDescription": "Xuất phiên hiện tại thành một liên kết web có thể chia sẻ",
|
||||
"CmdJobsDescription": "Kiểm tra và kiểm soát các lệnh chạy ngầm",
|
||||
"CmdDispatchDescription": "Gửi tác nhân đám mây Daytona để tạo nhánh và mở PR",
|
||||
"CmdDispatchDescription": "Gửi tác nhân đám mây Codewhale để tạo nhánh và mở PR",
|
||||
"CmdLinksDescription": "Hiển thị liên kết Codewhale, cộng đồng và nhà cung cấp",
|
||||
"CmdLoadDescription": "Tải phiên làm việc từ tệp",
|
||||
"CmdLogoutDescription": "Xóa khóa API và quay lại bước thiết lập",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"CmdLspDescription": "切换 LSP 诊断的开启或关闭",
|
||||
"CmdShareDescription": "将当前会话导出为可共享的 Web URL",
|
||||
"CmdJobsDescription": "查看并管理后台 shell 作业",
|
||||
"CmdDispatchDescription": "派遣 Daytona 云端代理以创建分支并打开拉取请求",
|
||||
"CmdDispatchDescription": "派遣 Codewhale 云端代理以创建分支并打开拉取请求",
|
||||
"CmdLinksDescription": "显示 Codewhale、社区与提供商链接",
|
||||
"CmdLoadDescription": "从文件加载会话",
|
||||
"CmdLogoutDescription": "清除 API 密钥并返回设置",
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
"CmdHotbarDescription": "開啟 Hotbar 設定",
|
||||
"CmdInitDescription": "為項目生成 AGENTS.md",
|
||||
"CmdJobsDescription": "檢視並管理後台 shell 作業",
|
||||
"CmdDispatchDescription": "派遣 Daytona 雲端代理以建立分支並開啟拉取請求",
|
||||
"CmdDispatchDescription": "派遣 Codewhale 雲端代理以建立分支並開啟拉取請求",
|
||||
"CmdLaneDescription": "檢視與控制持久化的 Lane(執行中的工作流程)",
|
||||
"CmdLinksDescription": "顯示 Codewhale、社群與提供商鏈接",
|
||||
"CmdLoadDescription": "從檔案載入工作階段",
|
||||
|
||||
@@ -32,7 +32,6 @@ const CWC_DAYTONA_TOKEN_ENV: &str = "CWC_DAYTONA_TOKEN";
|
||||
const CWC_DAYTONA_ENDPOINT_ENV: &str = "CWC_DAYTONA_ENDPOINT";
|
||||
const KEYRING_SLOT: &str = "daytona";
|
||||
const DEFAULT_DAYTONA_API: &str = "https://app.daytona.io/api";
|
||||
const ENABLE_DOCS: &str = "docs/DAYTONA_CLOUD_DISPATCH.md";
|
||||
|
||||
/// Explicit PR forge. Never inferred from a generic "origin means GitHub" rule.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -412,19 +411,18 @@ pub fn discover_credentials() -> CredentialState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Daytona CLI presence is not a credential. Used for truthful status copy.
|
||||
pub fn daytona_cli_version() -> Option<String> {
|
||||
let output = Command::new("daytona").arg("--version").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let line = text.lines().next()?.trim();
|
||||
if line.is_empty() || line.len() > 80 {
|
||||
None
|
||||
} else {
|
||||
Some(line.chars().filter(|ch| !ch.is_control()).collect())
|
||||
}
|
||||
/// Codewhale membership check. `/dispatch` cloud agents ship with the
|
||||
/// account, so the fail-closed gate is sign-in — never a provider key.
|
||||
fn membership_signed_in() -> bool {
|
||||
let Ok(secrets) = codewhale_secrets::account::secure_account_session_secrets() else {
|
||||
return false;
|
||||
};
|
||||
let store = codewhale_secrets::account::AccountSessionStore::new(
|
||||
secrets,
|
||||
None,
|
||||
codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE,
|
||||
);
|
||||
matches!(store.load(), Ok(Some(_)))
|
||||
}
|
||||
|
||||
/// Auto-decide leftover: Codewhale may propose, but never confirm itself.
|
||||
@@ -482,7 +480,7 @@ pub fn execute_dispatch(
|
||||
job.sandbox_id = Some(receipt.sandbox_id);
|
||||
job.status = CloudJobStatus::Running;
|
||||
job.pr_url = None;
|
||||
job.note = "Daytona sandbox created. PR open is not claimed: this slice does not fake a forge pull request.".to_string();
|
||||
job.note = "Cloud agent sandbox created. PR open is not claimed: this slice does not fake a forge pull request.".to_string();
|
||||
store.save(&job)?;
|
||||
Ok(DispatchOutcome::Accepted(job))
|
||||
}
|
||||
@@ -490,7 +488,7 @@ pub fn execute_dispatch(
|
||||
job.status = CloudJobStatus::Failed;
|
||||
job.refusal = Some(sanitize_error(&error.to_string()));
|
||||
job.note = format!(
|
||||
"Daytona launch failed closed. {}",
|
||||
"Cloud agent launch failed closed. {}",
|
||||
job.refusal.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
store.save(&job)?;
|
||||
@@ -536,8 +534,8 @@ pub fn cancel_job(store: &CloudJobStore, id: &str) -> Result<CloudJob> {
|
||||
return Ok(job);
|
||||
}
|
||||
job.status = CloudJobStatus::Canceled;
|
||||
job.note = "Canceled locally. Live Daytona teardown is leftover when a sandbox was created."
|
||||
.to_string();
|
||||
job.note =
|
||||
"Canceled locally. Live sandbox teardown is leftover when one was created.".to_string();
|
||||
store.save(&job)?;
|
||||
Ok(job)
|
||||
}
|
||||
@@ -545,7 +543,7 @@ pub fn cancel_job(store: &CloudJobStore, id: &str) -> Result<CloudJob> {
|
||||
/// Human list used by `/jobs` (cloud kind) and `codewhale dispatch --list`.
|
||||
pub fn format_job_list(jobs: &[CloudJob]) -> String {
|
||||
if jobs.is_empty() {
|
||||
return "Cloud jobs (0)\nNo Daytona cloud-agent jobs. Use `codewhale dispatch <prompt>` or `/dispatch <prompt>`.".to_string();
|
||||
return "Cloud jobs (0)\nNo cloud-agent jobs yet. Use `codewhale dispatch <prompt>` or `/dispatch <prompt>`.".to_string();
|
||||
}
|
||||
let mut lines = vec![
|
||||
format!("Cloud jobs ({}) kind=cloud", jobs.len()),
|
||||
@@ -598,30 +596,28 @@ pub fn format_job(job: &CloudJob) -> String {
|
||||
|
||||
/// Status card for bare `/dispatch` and `codewhale dispatch --status`.
|
||||
pub fn format_status(remotes: &[GitRemote], credentials: &CredentialState) -> String {
|
||||
let mut lines = vec!["Codewhale Daytona cloud dispatch".to_string()];
|
||||
let mut lines = vec!["Codewhale cloud dispatch".to_string()];
|
||||
match credentials {
|
||||
CredentialState::Missing => {
|
||||
lines.push(format!(
|
||||
"Daytona: not configured (fail-closed). Set {DAYTONA_API_KEY_ENV} or keyring slot `{KEYRING_SLOT}`. See {ENABLE_DOCS}."
|
||||
));
|
||||
if membership_signed_in() {
|
||||
lines.push(
|
||||
"Cloud agents are not available for this account yet; cloud dispatch fails closed (no sandbox, no push, no PR)."
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"Cloud agents are included with your Codewhale membership. Sign in with `codewhale login` to enable `/dispatch`; cloud dispatch fails closed until then (no sandbox, no push, no PR)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
CredentialState::Present { source } => {
|
||||
let source = match source {
|
||||
CredentialSource::Env => DAYTONA_API_KEY_ENV,
|
||||
CredentialSource::CwcEnv => CWC_DAYTONA_TOKEN_ENV,
|
||||
CredentialSource::Keyring => "keyring:daytona",
|
||||
};
|
||||
lines.push(format!(
|
||||
"Daytona: credentials present ({source}). Confirmation is still required before spend or push."
|
||||
));
|
||||
CredentialState::Present { .. } => {
|
||||
lines.push(
|
||||
"Cloud agents: ready (account-linked). Confirmation is still required before spend or push."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
match daytona_cli_version() {
|
||||
Some(version) => lines.push(format!(
|
||||
"Daytona CLI: {version} (installed; not treated as a credential)."
|
||||
)),
|
||||
None => lines.push("Daytona CLI: not installed.".to_string()),
|
||||
}
|
||||
if remotes.is_empty() {
|
||||
lines.push("Remotes: none discovered.".to_string());
|
||||
} else {
|
||||
@@ -694,10 +690,7 @@ impl DaytonaLauncher for LiveDaytonaLauncher {
|
||||
let status = response.status();
|
||||
let text = response.text().unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"Daytona create failed (HTTP {}). Enablement: {ENABLE_DOCS}.",
|
||||
status.as_u16()
|
||||
);
|
||||
bail!("Cloud agent create failed (HTTP {status}).",);
|
||||
}
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&text).context("Daytona returned invalid JSON")?;
|
||||
@@ -717,10 +710,13 @@ impl DaytonaLauncher for LiveDaytonaLauncher {
|
||||
}
|
||||
|
||||
/// Status / enablement copy shared by CLI and TUI fail-closed paths.
|
||||
/// Membership-first: the gate is sign-in, never a provider key.
|
||||
pub fn missing_credentials_message() -> String {
|
||||
format!(
|
||||
"Daytona is not configured. Cloud dispatch fails closed (no sandbox, no push, no PR). Set {DAYTONA_API_KEY_ENV} or store the key in the Codewhale secret slot `{KEYRING_SLOT}` — never models.toml. See {ENABLE_DOCS}."
|
||||
)
|
||||
if membership_signed_in() {
|
||||
"Cloud agents are not available for this account yet; cloud dispatch fails closed (no sandbox, no push, no PR).".to_string()
|
||||
} else {
|
||||
"Cloud agents are included with your Codewhale membership. Sign in with `codewhale login` to enable `/dispatch`; cloud dispatch fails closed until then (no sandbox, no push, no PR).".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn prefer_named(remotes: &[GitRemote], forge: Forge) -> Option<SelectedRemote> {
|
||||
@@ -1044,7 +1040,8 @@ mod tests {
|
||||
assert!(job.confirmed);
|
||||
assert!(job.sandbox_id.is_none());
|
||||
assert!(job.pr_url.is_none());
|
||||
assert!(job.note.contains("DAYTONA_API_KEY"));
|
||||
assert!(job.note.contains("cloud dispatch fails closed"));
|
||||
assert!(!job.note.contains("DAYTONA"));
|
||||
assert!(!job.note.contains("sk-"));
|
||||
}
|
||||
|
||||
@@ -1110,8 +1107,8 @@ mod tests {
|
||||
#[test]
|
||||
fn missing_credentials_copy_never_embeds_a_secret() {
|
||||
let message = missing_credentials_message();
|
||||
assert!(message.contains(DAYTONA_API_KEY_ENV));
|
||||
assert!(message.contains(ENABLE_DOCS));
|
||||
assert!(message.contains("cloud dispatch fails closed"));
|
||||
assert!(!message.contains("DAYTONA"));
|
||||
assert!(!message.contains("sk-"));
|
||||
assert!(!message.contains("Bearer"));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `/dispatch` — first-class Daytona cloud-agent offload.
|
||||
//! `/dispatch` — first-class Codewhale cloud-agent offload.
|
||||
|
||||
use codewhale_command_contract::facets::CommandWorkspaceContext;
|
||||
use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
|
||||
@@ -210,8 +210,8 @@ mod tests {
|
||||
let result = dispatch(&mut workspace, None);
|
||||
assert!(!result.is_error, "{result:?}");
|
||||
let message = result.message.expect("status message");
|
||||
assert!(message.contains("Daytona"));
|
||||
assert!(message.contains("fail-closed") || message.contains("credentials present"));
|
||||
assert!(message.contains("Cloud agents"));
|
||||
assert!(message.contains("fails closed") || message.contains("ready"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user