mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
tui: plumb permission profile selection (#23708)
## Why The named-profile `/permissions` picker needs a small TUI action path that can select permission profiles without folding the menu UI and profile metadata into the same review. ## What changed - Carry permission-profile selections through the TUI app event flow. - Persist selected profiles while preserving the existing approval settings and guardrail prompts. - Keep the legacy `/permissions` picker behavior in this layer; the profile-mode menu stays in the follow-up PR. ## Stack 1. [#22931](https://github.com/openai/codex/pull/22931): runtime/session/network propagation for active permission profiles. 2. **This PR**: TUI selection plumbing and guardrail flow. 3. [#21559](https://github.com/openai/codex/pull/21559): profile-aware `/permissions` menu and custom profile display. <img width="1632" height="1186" alt="image" src="https://github.com/user-attachments/assets/69ddcd5e-b57c-468d-8c1d-246916323c15" /> ## Validation - `git diff --cached --check` before commit. - Full test run skipped at the user request while pushing the split stack.
This commit is contained in:
committed by
GitHub
Unverified
parent
e0e304b123
commit
fcff0d6c52
@@ -55,7 +55,6 @@ impl ChatWidget {
|
||||
} else {
|
||||
preset.label.to_string()
|
||||
};
|
||||
let preset_approval = AskForApproval::from(preset.approval);
|
||||
let base_description =
|
||||
Some(preset.description.replace(" (Identical to Agent mode)", ""));
|
||||
let approval_disabled_reason = match self
|
||||
@@ -70,86 +69,13 @@ impl ChatWidget {
|
||||
let default_disabled_reason = approval_disabled_reason
|
||||
.clone()
|
||||
.or_else(|| guardian_disabled_reason(false));
|
||||
let requires_confirmation = preset.id == "full-access"
|
||||
&& !self
|
||||
.config
|
||||
.notices
|
||||
.hide_full_access_warning
|
||||
.unwrap_or(false);
|
||||
let default_actions: Vec<SelectionAction> = if requires_confirmation {
|
||||
let preset_clone = preset.clone();
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenFullAccessConfirmation {
|
||||
preset: preset_clone.clone(),
|
||||
return_to_permissions: !include_read_only,
|
||||
});
|
||||
})]
|
||||
} else if preset.id == "auto" {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if WindowsSandboxLevel::from_config(&self.config)
|
||||
== WindowsSandboxLevel::Disabled
|
||||
{
|
||||
let preset_clone = preset.clone();
|
||||
if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& crate::legacy_core::windows_sandbox::sandbox_setup_is_complete(
|
||||
self.config.codex_home.as_path(),
|
||||
)
|
||||
{
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
});
|
||||
})]
|
||||
} else {
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})]
|
||||
}
|
||||
} else if let Some((sample_paths, extra_count, failed_scan)) =
|
||||
self.world_writable_warning_details()
|
||||
{
|
||||
let preset_clone = preset.clone();
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: Some(preset_clone.clone()),
|
||||
sample_paths: sample_paths.clone(),
|
||||
extra_count,
|
||||
failed_scan,
|
||||
});
|
||||
})]
|
||||
} else {
|
||||
Self::approval_preset_actions(
|
||||
preset_approval,
|
||||
preset.permission_profile.clone(),
|
||||
preset.active_permission_profile.clone(),
|
||||
base_name.clone(),
|
||||
ApprovalsReviewer::User,
|
||||
)
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Self::approval_preset_actions(
|
||||
preset_approval,
|
||||
preset.permission_profile.clone(),
|
||||
preset.active_permission_profile.clone(),
|
||||
base_name.clone(),
|
||||
ApprovalsReviewer::User,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Self::approval_preset_actions(
|
||||
preset_approval,
|
||||
preset.permission_profile.clone(),
|
||||
preset.active_permission_profile.clone(),
|
||||
base_name.clone(),
|
||||
ApprovalsReviewer::User,
|
||||
)
|
||||
};
|
||||
let default_actions = self.permission_mode_actions(
|
||||
&preset,
|
||||
base_name.clone(),
|
||||
ApprovalsReviewer::User,
|
||||
/*profile_selection*/ None,
|
||||
/*return_to_permissions*/ !include_read_only,
|
||||
);
|
||||
if preset.id == "auto" {
|
||||
items.push(SelectionItem {
|
||||
name: base_name.clone(),
|
||||
@@ -170,10 +96,7 @@ impl ChatWidget {
|
||||
if guardian_approval_enabled {
|
||||
items.push(SelectionItem {
|
||||
name: "Auto-review".to_string(),
|
||||
description: Some(
|
||||
"Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent."
|
||||
.to_string(),
|
||||
),
|
||||
description: Some(AUTO_REVIEW_DESCRIPTION.to_string()),
|
||||
is_current: current_review_policy == ApprovalsReviewer::AutoReview
|
||||
&& Self::preset_matches_current(
|
||||
current_approval,
|
||||
@@ -181,12 +104,12 @@ impl ChatWidget {
|
||||
self.config.cwd.as_path(),
|
||||
&preset,
|
||||
),
|
||||
actions: Self::approval_preset_actions(
|
||||
preset_approval,
|
||||
preset.permission_profile.clone(),
|
||||
preset.active_permission_profile.clone(),
|
||||
actions: self.permission_mode_actions(
|
||||
&preset,
|
||||
"Auto-review".to_string(),
|
||||
ApprovalsReviewer::AutoReview,
|
||||
/*profile_selection*/ None,
|
||||
/*return_to_permissions*/ !include_read_only,
|
||||
),
|
||||
dismiss_on_select: true,
|
||||
disabled_reason: approval_disabled_reason
|
||||
@@ -346,6 +269,97 @@ impl ChatWidget {
|
||||
})]
|
||||
}
|
||||
|
||||
pub(super) fn permission_profile_selection_actions(
|
||||
selection: PermissionProfileSelection,
|
||||
) -> Vec<SelectionAction> {
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::SelectPermissionProfile(selection.clone()));
|
||||
})]
|
||||
}
|
||||
|
||||
pub(super) fn permission_mode_actions(
|
||||
&self,
|
||||
preset: &ApprovalPreset,
|
||||
label: String,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
profile_selection: Option<PermissionProfileSelection>,
|
||||
return_to_permissions: bool,
|
||||
) -> Vec<SelectionAction> {
|
||||
let apply_actions = || {
|
||||
profile_selection.clone().map_or_else(
|
||||
|| {
|
||||
Self::approval_preset_actions(
|
||||
AskForApproval::from(preset.approval),
|
||||
preset.permission_profile.clone(),
|
||||
preset.active_permission_profile.clone(),
|
||||
label.clone(),
|
||||
approvals_reviewer,
|
||||
)
|
||||
},
|
||||
Self::permission_profile_selection_actions,
|
||||
)
|
||||
};
|
||||
let requires_confirmation = approvals_reviewer == ApprovalsReviewer::User
|
||||
&& preset.id == "full-access"
|
||||
&& !self
|
||||
.config
|
||||
.notices
|
||||
.hide_full_access_warning
|
||||
.unwrap_or(false);
|
||||
if requires_confirmation {
|
||||
let preset = preset.clone();
|
||||
return vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenFullAccessConfirmation {
|
||||
preset: preset.clone(),
|
||||
return_to_permissions,
|
||||
profile_selection: profile_selection.clone(),
|
||||
});
|
||||
})];
|
||||
}
|
||||
if approvals_reviewer == ApprovalsReviewer::User && preset.id == "auto" {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled {
|
||||
let preset = preset.clone();
|
||||
if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& crate::legacy_core::windows_sandbox::sandbox_setup_is_complete(
|
||||
self.config.codex_home.as_path(),
|
||||
)
|
||||
{
|
||||
return vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset.clone(),
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
profile_selection: profile_selection.clone(),
|
||||
});
|
||||
})];
|
||||
}
|
||||
return vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset.clone(),
|
||||
profile_selection: profile_selection.clone(),
|
||||
});
|
||||
})];
|
||||
}
|
||||
if let Some((sample_paths, extra_count, failed_scan)) =
|
||||
self.world_writable_warning_details()
|
||||
{
|
||||
let preset = preset.clone();
|
||||
return vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: Some(preset.clone()),
|
||||
profile_selection: profile_selection.clone(),
|
||||
sample_paths: sample_paths.clone(),
|
||||
extra_count,
|
||||
failed_scan,
|
||||
});
|
||||
})];
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_actions()
|
||||
}
|
||||
|
||||
pub(super) fn preset_matches_current(
|
||||
current_approval: AskForApproval,
|
||||
current_permission_profile: &PermissionProfile,
|
||||
@@ -389,6 +403,7 @@ impl ChatWidget {
|
||||
&mut self,
|
||||
preset: ApprovalPreset,
|
||||
return_to_permissions: bool,
|
||||
profile_selection: Option<PermissionProfileSelection>,
|
||||
) {
|
||||
let selected_name = preset.label.to_string();
|
||||
let approval = AskForApproval::from(preset.approval);
|
||||
@@ -406,23 +421,33 @@ impl ChatWidget {
|
||||
));
|
||||
let header = ColumnRenderable::with(header_children);
|
||||
|
||||
let mut accept_actions = Self::approval_preset_actions(
|
||||
approval,
|
||||
preset.permission_profile.clone(),
|
||||
preset.active_permission_profile.clone(),
|
||||
selected_name.clone(),
|
||||
ApprovalsReviewer::User,
|
||||
let mut accept_actions = profile_selection.clone().map_or_else(
|
||||
|| {
|
||||
Self::approval_preset_actions(
|
||||
approval,
|
||||
preset.permission_profile.clone(),
|
||||
preset.active_permission_profile.clone(),
|
||||
selected_name.clone(),
|
||||
ApprovalsReviewer::User,
|
||||
)
|
||||
},
|
||||
Self::permission_profile_selection_actions,
|
||||
);
|
||||
accept_actions.push(Box::new(|tx| {
|
||||
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
|
||||
}));
|
||||
|
||||
let mut accept_and_remember_actions = Self::approval_preset_actions(
|
||||
approval,
|
||||
preset.permission_profile,
|
||||
preset.active_permission_profile,
|
||||
selected_name,
|
||||
ApprovalsReviewer::User,
|
||||
let mut accept_and_remember_actions = profile_selection.map_or_else(
|
||||
|| {
|
||||
Self::approval_preset_actions(
|
||||
approval,
|
||||
preset.permission_profile,
|
||||
preset.active_permission_profile,
|
||||
selected_name,
|
||||
ApprovalsReviewer::User,
|
||||
)
|
||||
},
|
||||
Self::permission_profile_selection_actions,
|
||||
);
|
||||
accept_and_remember_actions.push(Box::new(|tx| {
|
||||
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
|
||||
|
||||
@@ -30,6 +30,30 @@ impl ChatWidget {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_permission_profile_with_active_profile(
|
||||
&mut self,
|
||||
profile: PermissionProfile,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
) -> ConstraintResult<()> {
|
||||
self.config
|
||||
.permissions
|
||||
.set_permission_profile_from_session_snapshot(
|
||||
PermissionProfileSnapshot::from_session_snapshot(
|
||||
profile,
|
||||
active_permission_profile,
|
||||
),
|
||||
)?;
|
||||
self.refresh_status_surfaces();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_permission_network(
|
||||
&mut self,
|
||||
network: Option<crate::legacy_core::config::NetworkProxySpec>,
|
||||
) {
|
||||
self.config.permissions.network = network;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option<WindowsSandboxModeToml>) {
|
||||
self.config.permissions.windows_sandbox_mode = mode;
|
||||
|
||||
@@ -297,7 +297,10 @@ impl ChatWidget {
|
||||
&[],
|
||||
);
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
|
||||
.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset,
|
||||
profile_selection: None,
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
|
||||
@@ -166,7 +166,9 @@ async fn full_access_confirmation_popup_snapshot() {
|
||||
.into_iter()
|
||||
.find(|preset| preset.id == "full-access")
|
||||
.expect("full access preset");
|
||||
chat.open_full_access_confirmation(preset, /*return_to_permissions*/ false);
|
||||
chat.open_full_access_confirmation(
|
||||
preset, /*return_to_permissions*/ false, /*profile_selection*/ None,
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert_chatwidget_snapshot!("full_access_confirmation_popup", popup);
|
||||
@@ -181,7 +183,7 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
|
||||
.into_iter()
|
||||
.find(|preset| preset.id == "auto")
|
||||
.expect("auto preset");
|
||||
chat.open_windows_sandbox_enable_prompt(preset);
|
||||
chat.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 120);
|
||||
assert!(
|
||||
@@ -799,8 +801,9 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation()
|
||||
AppEvent::OpenFullAccessConfirmation {
|
||||
preset,
|
||||
return_to_permissions,
|
||||
profile_selection,
|
||||
} => {
|
||||
open_confirmation_event = Some((preset, return_to_permissions));
|
||||
open_confirmation_event = Some((preset, return_to_permissions, profile_selection));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -811,9 +814,9 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation()
|
||||
"did not expect history cell before confirming full access"
|
||||
);
|
||||
}
|
||||
let (preset, return_to_permissions) =
|
||||
let (preset, return_to_permissions, profile_selection) =
|
||||
open_confirmation_event.expect("expected full access confirmation event");
|
||||
chat.open_full_access_confirmation(preset, return_to_permissions);
|
||||
chat.open_full_access_confirmation(preset, return_to_permissions, profile_selection);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
|
||||
@@ -38,6 +38,7 @@ impl ChatWidget {
|
||||
pub(crate) fn open_world_writable_warning_confirmation(
|
||||
&mut self,
|
||||
preset: Option<ApprovalPreset>,
|
||||
profile_selection: Option<PermissionProfileSelection>,
|
||||
sample_paths: Vec<String>,
|
||||
extra_count: usize,
|
||||
failed_scan: bool,
|
||||
@@ -111,7 +112,9 @@ impl ChatWidget {
|
||||
tx.send(AppEvent::SkipNextWorldWritableScan);
|
||||
}));
|
||||
}
|
||||
if let (Some(approval), Some(permission_profile), Some(active_permission_profile)) = (
|
||||
if let Some(selection) = profile_selection.clone() {
|
||||
accept_actions.extend(Self::permission_profile_selection_actions(selection));
|
||||
} else if let (Some(approval), Some(permission_profile), Some(active_permission_profile)) = (
|
||||
approval,
|
||||
permission_profile.clone(),
|
||||
active_permission_profile.clone(),
|
||||
@@ -130,7 +133,10 @@ impl ChatWidget {
|
||||
tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true));
|
||||
tx.send(AppEvent::PersistWorldWritableWarningAcknowledged);
|
||||
}));
|
||||
if let (Some(approval), Some(permission_profile), Some(active_permission_profile)) =
|
||||
if let Some(selection) = profile_selection {
|
||||
accept_and_remember_actions
|
||||
.extend(Self::permission_profile_selection_actions(selection));
|
||||
} else if let (Some(approval), Some(permission_profile), Some(active_permission_profile)) =
|
||||
(approval, permission_profile, active_permission_profile)
|
||||
{
|
||||
accept_and_remember_actions.extend(Self::approval_preset_actions(
|
||||
@@ -171,6 +177,7 @@ impl ChatWidget {
|
||||
pub(crate) fn open_world_writable_warning_confirmation(
|
||||
&mut self,
|
||||
_preset: Option<ApprovalPreset>,
|
||||
_profile_selection: Option<PermissionProfileSelection>,
|
||||
_sample_paths: Vec<String>,
|
||||
_extra_count: usize,
|
||||
_failed_scan: bool,
|
||||
@@ -178,7 +185,11 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(
|
||||
&mut self,
|
||||
preset: ApprovalPreset,
|
||||
profile_selection: Option<PermissionProfileSelection>,
|
||||
) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
if !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
|
||||
@@ -202,6 +213,7 @@ impl ChatWidget {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
profile_selection: profile_selection.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
@@ -245,6 +257,7 @@ impl ChatWidget {
|
||||
let accept_otel = self.session_telemetry.clone();
|
||||
let legacy_otel = self.session_telemetry.clone();
|
||||
let legacy_preset = preset.clone();
|
||||
let legacy_profile_selection = profile_selection.clone();
|
||||
let quit_otel = self.session_telemetry.clone();
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
@@ -258,6 +271,7 @@ impl ChatWidget {
|
||||
);
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: preset.clone(),
|
||||
profile_selection: profile_selection.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
@@ -274,6 +288,7 @@ impl ChatWidget {
|
||||
);
|
||||
tx.send(AppEvent::BeginWindowsSandboxLegacySetup {
|
||||
preset: legacy_preset.clone(),
|
||||
profile_selection: legacy_profile_selection.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
@@ -305,10 +320,19 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(
|
||||
&mut self,
|
||||
_preset: ApprovalPreset,
|
||||
_profile_selection: Option<PermissionProfileSelection>,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) {
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
preset: ApprovalPreset,
|
||||
profile_selection: Option<PermissionProfileSelection>,
|
||||
) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
@@ -328,6 +352,8 @@ impl ChatWidget {
|
||||
|
||||
let elevated_preset = preset.clone();
|
||||
let legacy_preset = preset;
|
||||
let elevated_profile_selection = profile_selection.clone();
|
||||
let legacy_profile_selection = profile_selection;
|
||||
let quit_otel = self.session_telemetry.clone();
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
@@ -344,6 +370,7 @@ impl ChatWidget {
|
||||
);
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: preset.clone(),
|
||||
profile_selection: elevated_profile_selection.clone(),
|
||||
});
|
||||
}
|
||||
})],
|
||||
@@ -364,6 +391,7 @@ impl ChatWidget {
|
||||
);
|
||||
tx.send(AppEvent::BeginWindowsSandboxLegacySetup {
|
||||
preset: preset.clone(),
|
||||
profile_selection: legacy_profile_selection.clone(),
|
||||
});
|
||||
}
|
||||
})],
|
||||
@@ -396,7 +424,12 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {}
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
_preset: ApprovalPreset,
|
||||
_profile_selection: Option<PermissionProfileSelection>,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) {
|
||||
@@ -406,7 +439,7 @@ impl ChatWidget {
|
||||
.into_iter()
|
||||
.find(|preset| preset.id == "auto")
|
||||
{
|
||||
self.open_windows_sandbox_enable_prompt(preset);
|
||||
self.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user