mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Delay the startup composer until first-login onboarding (#38643)
## Why On a pristine default installation, the provisional composer can appear before first-login onboarding takes over the terminal. ## What changed - Detect pristine local installations conservatively, accounting for existing Codex state, custom homes, access tokens, system configuration, and managed configuration sources, including macOS managed preferences. - Keep the composer hidden and ignore draft input until onboarding completes, while still allowing startup cancellation with `Ctrl-C` or `Ctrl-D`. - Reveal the configured composer after onboarding for normal new-session launches; resume and fork pickers retain their existing startup flow. ## Testing - Cover pristine and existing home layouts, environment credentials, system and managed configuration, inaccessible state, onboarding input suppression, cancellation, and the transition to the composer. GitOrigin-RevId: c1a47ea3ba7c1ff1dd229895c0362b6dd9bfd713
This commit is contained in:
@@ -34,6 +34,13 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_managed_preferences() -> io::Result<bool> {
|
||||
Ok(
|
||||
load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?.is_some()
|
||||
|| load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)?.is_some(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_managed_admin_config_layer(
|
||||
override_base64: Option<&str>,
|
||||
strict_config: bool,
|
||||
|
||||
@@ -730,6 +730,25 @@ fn system_requirements_toml_file_with_overrides(
|
||||
}
|
||||
}
|
||||
|
||||
/// Check local managed configuration sources without loading or parsing configuration.
|
||||
///
|
||||
/// Filesystem or managed-preference errors are returned so callers can conservatively avoid
|
||||
/// assuming that administrator-controlled configuration is absent.
|
||||
pub fn has_local_managed_configuration(codex_home: &Path) -> io::Result<bool> {
|
||||
if layer_io::managed_config_default_path(codex_home).try_exists()?
|
||||
|| system_requirements_toml_file()?.as_path().try_exists()?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if macos::has_managed_preferences()? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
|
||||
AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX))
|
||||
|
||||
@@ -176,6 +176,7 @@ mod startup_draft;
|
||||
mod startup_error;
|
||||
mod startup_hooks_review;
|
||||
mod startup_orchestration;
|
||||
mod startup_preflight;
|
||||
mod status;
|
||||
mod status_indicator_widget;
|
||||
mod streaming;
|
||||
@@ -1194,6 +1195,12 @@ async fn run_ratatui_app(
|
||||
initial_config
|
||||
};
|
||||
startup_draft.apply_config(&config);
|
||||
if !(cli.resume_picker || cli.fork_picker)
|
||||
&& let Err(err) = startup_draft.show(&mut tui)
|
||||
{
|
||||
shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await;
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
let missing_session_exit =
|
||||
|id_str: &str,
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/startup_draft_tests.rs
|
||||
expression: frames
|
||||
---
|
||||
before onboarding: hidden (0x0 viewport)
|
||||
---
|
||||
after onboarding:
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts
|
||||
@@ -51,6 +51,7 @@ const STARTUP_PASTE_NEWLINE_TIMEOUT: Duration = Duration::from_millis(120);
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum StartupDraftInitialScreen {
|
||||
Composer,
|
||||
Onboarding,
|
||||
SessionPicker,
|
||||
}
|
||||
|
||||
@@ -115,9 +116,7 @@ impl StartupDraft {
|
||||
pending_paste_newline: None,
|
||||
},
|
||||
};
|
||||
if initial_screen == StartupDraftInitialScreen::Composer {
|
||||
draft.pump.show(&mut draft.tui)?;
|
||||
}
|
||||
draft.pump.show_initial_screen(&mut draft.tui)?;
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
@@ -230,7 +229,15 @@ impl StartupDraftPump {
|
||||
self.bottom_pane.composer_draft_snapshot()
|
||||
}
|
||||
|
||||
/// Reveal the editable composer once a requested session picker has finished.
|
||||
/// Draw the initial composer only when no protected startup screen must appear first.
|
||||
fn show_initial_screen(&mut self, tui: &mut Tui) -> io::Result<()> {
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.show(tui)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reveal the editable composer once an expected protected screen has finished.
|
||||
pub(crate) fn show(&mut self, tui: &mut Tui) -> io::Result<()> {
|
||||
self.initial_screen = StartupDraftInitialScreen::Composer;
|
||||
self.draw(tui, tui.terminal.last_known_screen_size)
|
||||
@@ -274,7 +281,7 @@ impl StartupDraftPump {
|
||||
}
|
||||
match event {
|
||||
TuiEvent::Key(key) => {
|
||||
if self.initial_screen == StartupDraftInitialScreen::SessionPicker
|
||||
if self.initial_screen != StartupDraftInitialScreen::Composer
|
||||
&& !key_hint::ctrl(KeyCode::Char('c')).is_press(key)
|
||||
&& !key_hint::ctrl(KeyCode::Char('d')).is_press(key)
|
||||
{
|
||||
|
||||
@@ -499,6 +499,90 @@ async fn startup_draft_applies_editor_keymap_without_enabling_vim() {
|
||||
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_waits_for_onboarding_before_accepting_input() {
|
||||
let mut composer_pump = startup_test_pump(std::iter::empty());
|
||||
let mut composer_tui =
|
||||
crate::tui::test_support::make_test_tui().expect("create composer test terminal");
|
||||
composer_pump
|
||||
.show_initial_screen(&mut composer_tui)
|
||||
.expect("draw the composer when no protected screen is expected");
|
||||
assert!(!composer_tui.terminal.viewport_area.is_empty());
|
||||
drop(composer_tui);
|
||||
|
||||
let mut pump = startup_test_pump(
|
||||
[
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
|
||||
TuiEvent::Paste("not a draft".to_string()),
|
||||
TuiEvent::Draw,
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
pump.initial_screen = StartupDraftInitialScreen::Onboarding;
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
pump.show_initial_screen(&mut tui)
|
||||
.expect("keep the composer hidden until onboarding finishes");
|
||||
|
||||
pump.flush_pending_events(&mut tui)
|
||||
.await
|
||||
.expect("ignore input before onboarding owns the screen");
|
||||
assert!(pump.bottom_pane.composer_is_empty());
|
||||
assert!(tui.terminal.viewport_area.is_empty());
|
||||
let hidden_area = tui.terminal.viewport_area;
|
||||
let mut frames = format!(
|
||||
"before onboarding: hidden ({}x{} viewport)",
|
||||
hidden_area.width, hidden_area.height
|
||||
);
|
||||
|
||||
pump.show(&mut tui)
|
||||
.expect("show the composer after onboarding finishes");
|
||||
assert!(!tui.terminal.viewport_area.is_empty());
|
||||
let area = tui.terminal.viewport_area;
|
||||
let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
renderable.render(area, &mut buffer);
|
||||
let visible_frame = (area.top()..area.bottom())
|
||||
.map(|row| {
|
||||
(area.left()..area.right())
|
||||
.map(|column| buffer[(column, row)].symbol())
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.replace(crate::version::CODEX_CLI_VERSION, "<VERSION>");
|
||||
drop(renderable);
|
||||
frames.push_str(&format!("\n---\nafter onboarding:\n{visible_frame}"));
|
||||
insta::assert_snapshot!("startup_draft_onboarding_transition", frames);
|
||||
|
||||
pump.handle_event(
|
||||
&mut tui,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)),
|
||||
)
|
||||
.expect("edit the composer after onboarding finishes");
|
||||
pump.bottom_pane.flush_composer_paste_burst();
|
||||
assert_eq!(pump.bottom_pane.composer_text(), "y");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_allows_cancellation_before_onboarding_appears() {
|
||||
for character in ['c', 'd'] {
|
||||
let mut pump = startup_test_pump(std::iter::once(TuiEvent::Key(KeyEvent::new(
|
||||
KeyCode::Char(character),
|
||||
KeyModifiers::CONTROL,
|
||||
))));
|
||||
pump.initial_screen = StartupDraftInitialScreen::Onboarding;
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
|
||||
|
||||
let error = pump
|
||||
.flush_pending_events(&mut tui)
|
||||
.await
|
||||
.expect_err("cancel startup before onboarding appears");
|
||||
assert!(super::StartupCancelled::matches(&error));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_waits_for_session_picker_before_accepting_input() {
|
||||
let mut pump = startup_test_pump(
|
||||
|
||||
@@ -135,13 +135,6 @@ pub(super) async fn run_main_inner(
|
||||
.await;
|
||||
}
|
||||
|
||||
let initial_screen = if cli.resume_picker || cli.fork_picker {
|
||||
startup_draft::StartupDraftInitialScreen::SessionPicker
|
||||
} else {
|
||||
startup_draft::StartupDraftInitialScreen::Composer
|
||||
};
|
||||
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?;
|
||||
|
||||
let reuse_implicit_local_daemon = !workload_identity_selected
|
||||
&& can_reuse_implicit_local_daemon(
|
||||
&cli_kv_overrides,
|
||||
@@ -149,6 +142,25 @@ pub(super) async fn run_main_inner(
|
||||
strict_config,
|
||||
cli.bypass_hook_trust,
|
||||
);
|
||||
let initial_screen = if cli.resume_picker || cli.fork_picker {
|
||||
startup_draft::StartupDraftInitialScreen::SessionPicker
|
||||
} else if !cli.oss
|
||||
&& explicit_remote_endpoint.is_none()
|
||||
&& reuse_implicit_local_daemon
|
||||
&& launch_loader_overrides.packaged_defaults_path.is_none()
|
||||
&& startup_preflight::should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
codex_config::loader::system_config_toml_file(),
|
||||
|| codex_config::loader::has_local_managed_configuration(&codex_home),
|
||||
|name| std::env::var(name).ok(),
|
||||
)
|
||||
{
|
||||
startup_draft::StartupDraftInitialScreen::Onboarding
|
||||
} else {
|
||||
startup_draft::StartupDraftInitialScreen::Composer
|
||||
};
|
||||
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?;
|
||||
|
||||
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
|
||||
startup_draft
|
||||
.run_until(maybe_probe_default_daemon_socket(&codex_home))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Conservative first-install checks that run before the provisional composer appears.
|
||||
//!
|
||||
//! Any existing user, system, daemon, or authentication state keeps the composer visible.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
/// Hide the composer only on a first installation without user or machine-wide configuration.
|
||||
pub(super) fn should_delay_startup_composer_for_first_login(
|
||||
codex_home: &Path,
|
||||
system_config_path: io::Result<AbsolutePathBuf>,
|
||||
managed_configuration: impl FnOnce() -> io::Result<bool>,
|
||||
environment_variable: impl Fn(&str) -> Option<String>,
|
||||
) -> bool {
|
||||
if environment_variable("CODEX_HOME").is_some_and(|value| !value.is_empty())
|
||||
|| environment_variable(codex_login::CODEX_ACCESS_TOKEN_ENV_VAR)
|
||||
.is_some_and(|credential| !credential.trim().is_empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(system_config_path) = system_config_path else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(system_config_path.as_path().try_exists(), Ok(false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let pristine_home = match codex_home.try_exists() {
|
||||
Ok(false) => true,
|
||||
Err(_) => false,
|
||||
Ok(true) => {
|
||||
let Ok(mut entries) = std::fs::read_dir(codex_home) else {
|
||||
return false;
|
||||
};
|
||||
let Some(Ok(temporary_root)) = entries.next() else {
|
||||
return false;
|
||||
};
|
||||
if temporary_root.file_name() != "tmp"
|
||||
|| !temporary_root
|
||||
.file_type()
|
||||
.is_ok_and(|file_type| file_type.is_dir())
|
||||
|| entries.next().is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(mut entries) = std::fs::read_dir(temporary_root.path()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(Ok(arg0_root)) = entries.next() else {
|
||||
return false;
|
||||
};
|
||||
arg0_root.file_name() == "arg0"
|
||||
&& arg0_root
|
||||
.file_type()
|
||||
.is_ok_and(|file_type| file_type.is_dir())
|
||||
&& entries.next().is_none()
|
||||
}
|
||||
};
|
||||
|
||||
pristine_home && matches!(managed_configuration(), Ok(false))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "startup_preflight_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,201 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::should_delay_startup_composer_for_first_login;
|
||||
|
||||
#[test]
|
||||
fn startup_delays_composer_only_for_pristine_default_homes() -> std::io::Result<()> {
|
||||
let temporary_directory = TempDir::new()?;
|
||||
let codex_home = temporary_directory.path().join("codex-home");
|
||||
let system_config_path =
|
||||
AbsolutePathBuf::from_absolute_path(temporary_directory.path().join("system.toml"))?;
|
||||
|
||||
assert!(should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
assert!(should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|name| (name == codex_login::CODEX_ACCESS_TOKEN_ENV_VAR).then(|| " ".to_string()),
|
||||
));
|
||||
|
||||
std::fs::create_dir_all(codex_home.join("tmp").join("arg0"))?;
|
||||
assert!(should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
let helper_directory = codex_home
|
||||
.join("tmp")
|
||||
.join("arg0")
|
||||
.join("codex-arg0-session");
|
||||
std::fs::create_dir(&helper_directory)?;
|
||||
std::fs::write(helper_directory.join("apply_patch"), "")?;
|
||||
assert!(should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|name| (name == "CODEX_HOME").then(|| "/custom/home".to_string()),
|
||||
));
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|name| {
|
||||
(name == codex_login::CODEX_ACCESS_TOKEN_ENV_VAR).then(|| "access-token".to_string())
|
||||
},
|
||||
));
|
||||
for disabled_credential in [
|
||||
codex_login::OPENAI_API_KEY_ENV_VAR,
|
||||
codex_login::CODEX_API_KEY_ENV_VAR,
|
||||
] {
|
||||
assert!(should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|name| (name == disabled_credential).then(|| "disabled-key".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
for existing_state in ["auth.json", "config.toml", "history.jsonl", "sessions"] {
|
||||
let state_path = codex_home.join(existing_state);
|
||||
std::fs::write(&state_path, "")?;
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
std::fs::remove_file(state_path)?;
|
||||
}
|
||||
|
||||
let daemon_directory = codex_home.join("app-server-control");
|
||||
std::fs::create_dir(&daemon_directory)?;
|
||||
std::fs::write(daemon_directory.join("app-server-control.sock"), "")?;
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| panic!("existing homes should not probe managed configuration"),
|
||||
|_| None,
|
||||
));
|
||||
std::fs::remove_file(daemon_directory.join("app-server-control.sock"))?;
|
||||
std::fs::remove_dir(daemon_directory)?;
|
||||
|
||||
let additional_temporary_state = codex_home.join("tmp").join("other");
|
||||
std::fs::write(&additional_temporary_state, "")?;
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
|
||||
let invalid_home = temporary_directory.path().join("invalid-home");
|
||||
std::fs::write(&invalid_home, "not a directory")?;
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&invalid_home,
|
||||
Ok(system_config_path),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_keeps_composer_when_home_state_cannot_be_confirmed() -> std::io::Result<()> {
|
||||
let temporary_directory = TempDir::new()?;
|
||||
let codex_home = temporary_directory.path().join("codex-home");
|
||||
let system_config_path =
|
||||
AbsolutePathBuf::from_absolute_path(temporary_directory.path().join("system.toml"))?;
|
||||
|
||||
std::fs::create_dir(&codex_home)?;
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
|
||||
std::fs::write(codex_home.join("tmp"), "not a directory")?;
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_keeps_composer_when_system_configuration_is_possible() -> std::io::Result<()> {
|
||||
let temporary_directory = TempDir::new()?;
|
||||
let codex_home = temporary_directory.path().join("codex-home");
|
||||
let system_config_path =
|
||||
AbsolutePathBuf::from_absolute_path(temporary_directory.path().join("system.toml"))?;
|
||||
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Err(std::io::Error::other(
|
||||
"system configuration path is unavailable"
|
||||
)),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let blocking_parent = temporary_directory.path().join("blocking-parent");
|
||||
std::fs::write(&blocking_parent, "not a directory")?;
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
AbsolutePathBuf::from_absolute_path(blocking_parent.join("system.toml")),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::write(system_config_path.as_path(), "model_provider = 'local'")?;
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path),
|
||||
|| Ok(false),
|
||||
|_| None,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_keeps_composer_when_managed_configuration_is_possible() -> std::io::Result<()> {
|
||||
let temporary_directory = TempDir::new()?;
|
||||
let codex_home = temporary_directory.path().join("codex-home");
|
||||
let system_config_path =
|
||||
AbsolutePathBuf::from_absolute_path(temporary_directory.path().join("system.toml"))?;
|
||||
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path.clone()),
|
||||
|| Ok(true),
|
||||
|_| None,
|
||||
));
|
||||
assert!(!should_delay_startup_composer_for_first_login(
|
||||
&codex_home,
|
||||
Ok(system_config_path),
|
||||
|| Err(std::io::Error::other(
|
||||
"managed configuration is inaccessible"
|
||||
)),
|
||||
|_| None,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user