feat(oauth): Phase C — social login on OAuth consent screen
Enable dashboard users (GitHub/Google social login) to approve OAuth consent without needing to create and paste an API key. Adds mhu_ user key support, session-based consent with __session__ marker, social login links on the consent page, and return URL redirect after login. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2334,6 +2334,20 @@ async def auth_provider_redirect(request: Request) -> Response:
|
|||||||
secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
|
secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Save return URL if provided (for OAuth consent flow redirect-back)
|
||||||
|
next_url = request.query_params.get("next", "")
|
||||||
|
if next_url:
|
||||||
|
response.set_cookie(
|
||||||
|
key="mcp_auth_next",
|
||||||
|
value=next_url,
|
||||||
|
max_age=600,
|
||||||
|
httponly=True,
|
||||||
|
secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
except (RuntimeError, ValueError) as e:
|
except (RuntimeError, ValueError) as e:
|
||||||
logger.error("OAuth redirect failed for %s: %s", provider, e)
|
logger.error("OAuth redirect failed for %s: %s", provider, e)
|
||||||
@@ -2445,15 +2459,25 @@ async def auth_callback(request: Request) -> Response:
|
|||||||
role=user.get("role", "user"),
|
role=user.get("role", "user"),
|
||||||
)
|
)
|
||||||
|
|
||||||
response = RedirectResponse(url="/dashboard", status_code=303)
|
# Check for return URL (OAuth consent flow redirect-back)
|
||||||
|
next_url = request.cookies.get("mcp_auth_next", "")
|
||||||
|
# Validate: must be relative URL or same-origin to prevent open redirect
|
||||||
|
if next_url and next_url.startswith("/"):
|
||||||
|
redirect_to = next_url
|
||||||
|
else:
|
||||||
|
redirect_to = "/dashboard"
|
||||||
|
|
||||||
|
response = RedirectResponse(url=redirect_to, status_code=303)
|
||||||
dashboard_auth = get_dashboard_auth()
|
dashboard_auth = get_dashboard_auth()
|
||||||
dashboard_auth.set_session_cookie(response, token)
|
dashboard_auth.set_session_cookie(response, token)
|
||||||
response.delete_cookie(key="oauth_state")
|
response.delete_cookie(key="oauth_state")
|
||||||
|
response.delete_cookie(key="mcp_auth_next", path="/")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"OAuth login successful: %s via %s",
|
"OAuth login successful: %s via %s (redirect=%s)",
|
||||||
user["email"],
|
user["email"],
|
||||||
provider,
|
provider,
|
||||||
|
redirect_to,
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,52 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
<!-- API Key Input -->
|
<!-- Authentication -->
|
||||||
|
{% if session_user %}
|
||||||
|
<!-- Session-based consent (user already logged in) -->
|
||||||
|
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-green-800 dark:text-green-300">
|
||||||
|
{% if lang == 'fa' %}وارد شده به عنوان{% else %}Logged in as{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-green-700 dark:text-green-400">
|
||||||
|
{{ session_user.name or session_user.email or 'User' }}
|
||||||
|
{% if session_user.email %}
|
||||||
|
<span class="text-green-600 dark:text-green-500">({{ session_user.email }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="api_key" value="__session__" id="session_marker">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
یا <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">با کلید API وارد شوید</a>
|
||||||
|
{% else %}
|
||||||
|
Or <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">enter an API key instead</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden API key input (shown when user clicks "enter an API key instead") -->
|
||||||
|
<div id="api-key-section" class="hidden" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<label for="api_key_input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{{ t.enter_api_key }}
|
||||||
|
</label>
|
||||||
|
<input type="password" id="api_key_input" name="api_key_manual"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
|
||||||
|
placeholder="{{ t.api_key_placeholder }}" autocomplete="off"
|
||||||
|
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">{{ t.api_key_note }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- API key mode (no active session) -->
|
||||||
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{{ t.enter_api_key }}
|
{{ t.enter_api_key }}
|
||||||
@@ -91,7 +136,33 @@
|
|||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
{{ t.api_key_note }}
|
{{ t.api_key_note }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if return_url %}
|
||||||
|
<!-- Social login link -->
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
یا با حساب کاربری وارد شوید:
|
||||||
|
{% else %}
|
||||||
|
Or log in with your account:
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<a href="/auth/github?next={{ return_url | urlencode }}"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a href="/auth/google?next={{ return_url | urlencode }}"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 text-gray-700 text-sm rounded-lg border border-gray-300 transition-colors">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
|
Google
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
@@ -131,29 +202,51 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
function showApiKeyInput() {
|
||||||
|
// Switch from session mode to API key input mode
|
||||||
|
document.getElementById('api-key-section').classList.remove('hidden');
|
||||||
|
// Remove the __session__ hidden input
|
||||||
|
var hiddenInput = document.getElementById('session_marker');
|
||||||
|
if (hiddenInput) hiddenInput.remove();
|
||||||
|
// Make the manual input the actual api_key field
|
||||||
|
var manualInput = document.getElementById('api_key_input');
|
||||||
|
if (manualInput) {
|
||||||
|
manualInput.name = 'api_key';
|
||||||
|
manualInput.required = true;
|
||||||
|
manualInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Client-side validation
|
// Client-side validation
|
||||||
document.getElementById('authForm').addEventListener('submit', function(e) {
|
document.getElementById('authForm').addEventListener('submit', function(e) {
|
||||||
const apiKey = document.getElementById('api_key').value;
|
var action = e.submitter.value;
|
||||||
const action = e.submitter.value;
|
|
||||||
|
|
||||||
if (action === 'approve') {
|
if (action === 'approve') {
|
||||||
|
// Session-based consent — no API key validation needed
|
||||||
|
var sessionInput = document.getElementById('session_marker');
|
||||||
|
if (sessionInput) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiKeyEl = document.getElementById('api_key') || document.getElementById('api_key_input');
|
||||||
|
var apiKey = apiKeyEl ? apiKeyEl.value : '';
|
||||||
|
|
||||||
if (!apiKey || apiKey.length < 10) {
|
if (!apiKey || apiKey.length < 10) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const lang = '{{ lang }}';
|
var lang = '{{ lang }}';
|
||||||
const message = lang === 'fa'
|
var message = lang === 'fa'
|
||||||
? 'لطفاً یک API Key معتبر وارد کنید'
|
? 'لطفاً یک API Key معتبر وارد کنید'
|
||||||
: 'Please enter a valid API key';
|
: 'Please enter a valid API key';
|
||||||
alert(message);
|
alert(message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_')) {
|
if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_') && !apiKey.startsWith('mhu_')) {
|
||||||
const lang = '{{ lang }}';
|
var lang2 = '{{ lang }}';
|
||||||
const message = lang === 'fa'
|
var msg = lang2 === 'fa'
|
||||||
? 'فرمت API Key غیرمعمول به نظر میرسد. ادامه میدهید؟'
|
? 'فرمت API Key غیرمعمول به نظر میرسد. ادامه میدهید؟'
|
||||||
: 'The API key format looks unusual. Continue anyway?';
|
: 'The API key format looks unusual. Continue anyway?';
|
||||||
const confirm = window.confirm(message);
|
if (!window.confirm(msg)) {
|
||||||
if (!confirm) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -161,13 +254,15 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus effect
|
// Focus effect for API key input (if present)
|
||||||
const apiKeyInput = document.getElementById('api_key');
|
var apiKeyInput = document.getElementById('api_key');
|
||||||
apiKeyInput.addEventListener('focus', function() {
|
if (apiKeyInput) {
|
||||||
this.parentElement.classList.add('ring-2', 'ring-purple-200');
|
apiKeyInput.addEventListener('focus', function() {
|
||||||
});
|
this.parentElement.classList.add('ring-2', 'ring-purple-200');
|
||||||
apiKeyInput.addEventListener('blur', function() {
|
});
|
||||||
this.parentElement.classList.remove('ring-2', 'ring-purple-200');
|
apiKeyInput.addEventListener('blur', function() {
|
||||||
});
|
this.parentElement.classList.remove('ring-2', 'ring-purple-200');
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
110
server.py
110
server.py
@@ -2394,6 +2394,37 @@ async def oauth_authorize(request: Request):
|
|||||||
# Generate CSRF token
|
# Generate CSRF token
|
||||||
csrf_token = csrf_manager.generate_token()
|
csrf_token = csrf_manager.generate_token()
|
||||||
|
|
||||||
|
# Detect existing dashboard session for session-based consent
|
||||||
|
session_user = None
|
||||||
|
try:
|
||||||
|
from core.dashboard.auth import get_dashboard_auth
|
||||||
|
|
||||||
|
dashboard_auth = get_dashboard_auth()
|
||||||
|
|
||||||
|
# Try OAuth user session first (GitHub/Google login)
|
||||||
|
user_session = dashboard_auth.get_user_session_from_request(request)
|
||||||
|
if user_session and user_session.get("user_id"):
|
||||||
|
session_user = {
|
||||||
|
"user_id": user_session["user_id"],
|
||||||
|
"email": user_session.get("email", ""),
|
||||||
|
"name": user_session.get("name", ""),
|
||||||
|
"role": user_session.get("role", "user"),
|
||||||
|
"type": "oauth_user",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Try admin session
|
||||||
|
admin_session = dashboard_auth.get_session_from_request(request)
|
||||||
|
if admin_session:
|
||||||
|
session_user = {
|
||||||
|
"user_id": "admin",
|
||||||
|
"email": "",
|
||||||
|
"name": "Admin",
|
||||||
|
"role": "admin",
|
||||||
|
"type": admin_session.user_type,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"No dashboard session detected for OAuth consent: {e}")
|
||||||
|
|
||||||
# Get client info
|
# Get client info
|
||||||
from core.oauth import get_client_registry
|
from core.oauth import get_client_registry
|
||||||
|
|
||||||
@@ -2432,6 +2463,8 @@ async def oauth_authorize(request: Request):
|
|||||||
"csrf_token": csrf_token,
|
"csrf_token": csrf_token,
|
||||||
"lang": lang, # Language code (en/fa)
|
"lang": lang, # Language code (en/fa)
|
||||||
"t": translations, # All translations for this language
|
"t": translations, # All translations for this language
|
||||||
|
"session_user": session_user,
|
||||||
|
"return_url": str(request.url),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2556,10 +2589,12 @@ async def oauth_authorize_confirm(request: Request):
|
|||||||
state=params.get("state"),
|
state=params.get("state"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate API Key
|
# Validate authentication (API Key or dashboard session)
|
||||||
api_key = params.get("api_key")
|
api_key = params.get("api_key", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise OAuthError(error="invalid_request", error_description="API Key is required")
|
raise OAuthError(
|
||||||
|
error="invalid_request", error_description="Authentication is required"
|
||||||
|
)
|
||||||
|
|
||||||
api_key_id = None
|
api_key_id = None
|
||||||
api_key_project_id = None
|
api_key_project_id = None
|
||||||
@@ -2567,13 +2602,12 @@ async def oauth_authorize_confirm(request: Request):
|
|||||||
granted_scope = validated["scope"]
|
granted_scope = validated["scope"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try as regular API key first
|
# Path 1: Project API key (cmp_)
|
||||||
if api_key.startswith("cmp_"):
|
if api_key.startswith("cmp_"):
|
||||||
# Validate without project/scope check (we'll use its values)
|
|
||||||
key_id = api_key_manager.validate_key(
|
key_id = api_key_manager.validate_key(
|
||||||
api_key,
|
api_key,
|
||||||
project_id="*", # Skip project check
|
project_id="*",
|
||||||
required_scope="read", # Skip scope check
|
required_scope="read",
|
||||||
skip_project_check=True,
|
skip_project_check=True,
|
||||||
)
|
)
|
||||||
api_key_info = api_key_manager.keys.get(key_id)
|
api_key_info = api_key_manager.keys.get(key_id)
|
||||||
@@ -2581,9 +2615,67 @@ async def oauth_authorize_confirm(request: Request):
|
|||||||
api_key_project_id = api_key_info.project_id
|
api_key_project_id = api_key_info.project_id
|
||||||
api_key_scope = api_key_info.scope
|
api_key_scope = api_key_info.scope
|
||||||
|
|
||||||
# Try as Master API Key
|
# Path 2: User API key (mhu_)
|
||||||
|
elif api_key.startswith("mhu_"):
|
||||||
|
from core.user_keys import get_user_key_manager
|
||||||
|
|
||||||
|
user_key_mgr = get_user_key_manager()
|
||||||
|
key_info = await user_key_mgr.validate_key(api_key)
|
||||||
|
if key_info:
|
||||||
|
api_key_id = f"user:{key_info['user_id']}"
|
||||||
|
api_key_project_id = "*"
|
||||||
|
api_key_scope = key_info.get("scopes", "read write")
|
||||||
|
logger.info(
|
||||||
|
f"OAuth authorization: User API key validated - "
|
||||||
|
f"user_id={key_info['user_id']}, scope={api_key_scope}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise OAuthError(
|
||||||
|
error="access_denied",
|
||||||
|
error_description="Invalid or expired User API Key.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Path 3: Dashboard session (social login)
|
||||||
|
elif api_key == "__session__":
|
||||||
|
from core.dashboard.auth import get_dashboard_auth
|
||||||
|
|
||||||
|
dashboard_auth = get_dashboard_auth()
|
||||||
|
|
||||||
|
# Try OAuth user session (GitHub/Google)
|
||||||
|
user_session = dashboard_auth.get_user_session_from_request(request)
|
||||||
|
if user_session and user_session.get("user_id"):
|
||||||
|
user_role = user_session.get("role", "user")
|
||||||
|
api_key_id = f"user:{user_session['user_id']}"
|
||||||
|
api_key_project_id = "*"
|
||||||
|
if user_role == "admin":
|
||||||
|
api_key_scope = "read write admin"
|
||||||
|
else:
|
||||||
|
api_key_scope = "read write"
|
||||||
|
logger.info(
|
||||||
|
f"OAuth authorization: Session-based consent - "
|
||||||
|
f"user_id={user_session['user_id']}, "
|
||||||
|
f"email={user_session.get('email', '')}, "
|
||||||
|
f"role={user_role}, scope={api_key_scope}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Try admin session
|
||||||
|
admin_session = dashboard_auth.get_session_from_request(request)
|
||||||
|
if admin_session:
|
||||||
|
api_key_id = f"admin:{admin_session.session_id}"
|
||||||
|
api_key_project_id = "*"
|
||||||
|
api_key_scope = "read write admin"
|
||||||
|
logger.info(
|
||||||
|
f"OAuth authorization: Admin session consent - "
|
||||||
|
f"type={admin_session.user_type}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise OAuthError(
|
||||||
|
error="access_denied",
|
||||||
|
error_description="Session expired. Please log in again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Path 4: Master API Key
|
||||||
elif auth_manager.validate_master_key(api_key):
|
elif auth_manager.validate_master_key(api_key):
|
||||||
# Master key → Full access
|
|
||||||
api_key_id = "master"
|
api_key_id = "master"
|
||||||
api_key_project_id = "*"
|
api_key_project_id = "*"
|
||||||
api_key_scope = "read write admin"
|
api_key_scope = "read write admin"
|
||||||
|
|||||||
Reference in New Issue
Block a user