feat: expand Gmail OAuth scopes, unify Google callback URL, store Gmail creds on login
- Add gmail.readonly to GMAIL_SCOPES (gmail_service) and GMAIL_API_SCOPES (providers) so users().getProfile() no longer returns 403 insufficientPermissions - Consolidate Gmail scope list: GMAIL_SCOPES in gmail_service.py is the single source of truth; auth.py and providers.py now import and spread it - Add include_granted_scopes=true to both Gmail and login authorize URLs so scope additions take effect for previously-connected users - Add state=gmail_connect to the Gmail authorize URL; the shared /auth/callback page routes to gmailApi.saveCallback() when this state is present, otherwise falls through to the normal login flow - Google Sign-In authorize URL now requests all six scopes (openid, email, profile + 3 Gmail scopes) with access_type=offline, prompt=consent, and include_granted_scopes=true - POST /auth/google now stores Gmail credentials automatically after sign-in (non-fatal: login succeeds even if credential storage fails) - Settings Connect Gmail button now uses /auth/callback instead of /auth/gmail-callback - only ONE redirect URI needed in Google Cloud Console - URL-encode scope parameter in both authorize URL builders - Update auth_service._register_google scope to include all Gmail scopes - Update CHANGELOG.md and docs/TODO.md Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/d6e2de8b-088d-46f3-8127-137245c3ecdd
This commit is contained in:
@@ -7,7 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Unified Google OAuth flow**: Google Sign-In now requests all Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`) in the same consent screen, so users no longer need a separate "Connect Gmail" step after signing in with Google. Gmail credentials are stored automatically on successful sign-in.
|
||||||
|
- `include_granted_scopes=true` added to both the login and Gmail authorize URLs so scope additions take effect for users who previously connected.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `GMAIL_API_SCOPES` (providers endpoint) and `GMAIL_SCOPES` (GmailService) now include `gmail.readonly`, required for `users().getProfile()` access verification (fixes 403 insufficientPermissions errors).
|
||||||
|
- Google Sign-In authorize URL (`GET /auth/google/authorize-url`) now requests all six scopes with `access_type=offline`, `prompt=consent`, and `include_granted_scopes=true` so a refresh token is always issued.
|
||||||
|
- Gmail "Connect Gmail" button in Settings now redirects to `/auth/callback?state=gmail_connect` instead of the dedicated `/auth/gmail-callback` page, reducing the number of redirect URIs that must be registered in Google Cloud Console to one (`{origin}/auth/callback`).
|
||||||
|
- `auth_service.py` `get_google_user_info` now returns `access_token`, `refresh_token`, `expires_in`, and `scope` alongside user info so the login endpoint can persist Gmail credentials in the same request.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Gmail API `verify_access()` returning 403 for tokens that lacked a read-capable scope: added `gmail.readonly` to all scope lists.
|
||||||
|
|
||||||
- Fixed three ESLint errors that caused CI to fail: removed unused `_setUser` store binding and unused `useAuthStore` import from `login/page.tsx`; replaced unused `_err` catch binding with a bare `catch {}` in `login/page.tsx`; removed a `useEffect` in `settings/page.tsx` that called `setProfileForm` synchronously (flagged by `react-hooks/set-state-in-effect`) — the effect was redundant because `useState` already initialises the form from the auth store's `user` object, which is the same value passed as `initialData` to `useQuery`.
|
- Fixed three ESLint errors that caused CI to fail: removed unused `_setUser` store binding and unused `useAuthStore` import from `login/page.tsx`; replaced unused `_err` catch binding with a bare `catch {}` in `login/page.tsx`; removed a `useEffect` in `settings/page.tsx` that called `setProfileForm` synchronously (flagged by `react-hooks/set-state-in-effect`) — the effect was redundant because `useState` already initialises the form from the auth store's `user` object, which is the same value passed as `initialData` to `useQuery`.
|
||||||
- Fixed wizard to create new mail accounts showing a big grey screen: `bg-opacity-75` was removed in Tailwind CSS v4; replaced with the `/75` opacity modifier syntax (`bg-gray-500/75`) in `AddMailAccountModal` and `DashboardLayout` mobile overlay. Restructured the modal from the deprecated `inline-block align-bottom` centering trick to a proper flexbox layout with `relative z-10` on the modal content.
|
- Fixed wizard to create new mail accounts showing a big grey screen: `bg-opacity-75` was removed in Tailwind CSS v4; replaced with the `/75` opacity modifier syntax (`bg-gray-500/75`) in `AddMailAccountModal` and `DashboardLayout` mobile overlay. Restructured the modal from the deprecated `inline-block align-bottom` centering trick to a proper flexbox layout with `relative z-10` on the modal content.
|
||||||
- Fixed mail account creation always failing with a backend validation error: `email_address` and `forward_to` are required fields in the backend schema but were missing from the `AddMailAccountModal` form. Added both fields to the form — `email_address` is auto-synced from the username input, and `forward_to` (destination Gmail address) is a new explicit field pre-populated from the logged-in user's email. Also added `delivery_method` selector and `delete_after_forward` checkbox.
|
- Fixed mail account creation always failing with a backend validation error: `email_address` and `forward_to` are required fields in the backend schema but were missing from the `AddMailAccountModal` form. Added both fields to the form — `email_address` is auto-synced from the username input, and `forward_to` (destination Gmail address) is a new explicit field pre-populated from the logged-in user's email. Also added `delivery_method` selector and `delete_after_forward` checkbox.
|
||||||
|
|||||||
@@ -6,18 +6,32 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import quote as urlquote
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import verify_password, get_password_hash
|
from app.core.security import verify_password, get_password_hash, encrypt_credential
|
||||||
from app.models.database_models import User, SubscriptionTier
|
from app.models.database_models import User, SubscriptionTier, GmailCredential
|
||||||
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
|
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
|
||||||
from app.services.auth_service import oauth_service
|
from app.services.auth_service import oauth_service
|
||||||
|
from app.services.gmail_service import GmailService, GMAIL_SCOPES
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# All scopes requested during Google Sign-In so users only go through one OAuth
|
||||||
|
# consent screen for both login and Gmail API access.
|
||||||
|
# GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from
|
||||||
|
# gmail_service so the scope list stays in sync with what GmailService uses.
|
||||||
|
GOOGLE_LOGIN_SCOPES = [
|
||||||
|
"openid",
|
||||||
|
"email",
|
||||||
|
"profile",
|
||||||
|
*GMAIL_SCOPES,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
||||||
@@ -154,6 +168,66 @@ async def google_oauth(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# If Gmail tokens were returned (Gmail scopes were granted), store them so
|
||||||
|
# users don't need a separate "Connect Gmail" step after signing in.
|
||||||
|
google_access_token = user_info.get("access_token")
|
||||||
|
google_refresh_token = user_info.get("refresh_token")
|
||||||
|
if google_access_token:
|
||||||
|
try:
|
||||||
|
gmail_service = GmailService(
|
||||||
|
access_token=google_access_token,
|
||||||
|
refresh_token=google_refresh_token,
|
||||||
|
client_id=settings.GOOGLE_CLIENT_ID,
|
||||||
|
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
||||||
|
)
|
||||||
|
is_valid = await gmail_service.verify_access()
|
||||||
|
if is_valid:
|
||||||
|
token_expiry = datetime.now(timezone.utc) + timedelta(
|
||||||
|
seconds=int(user_info.get("expires_in", 3600))
|
||||||
|
)
|
||||||
|
encrypted_access = encrypt_credential(google_access_token)
|
||||||
|
encrypted_refresh = (
|
||||||
|
encrypt_credential(google_refresh_token)
|
||||||
|
if google_refresh_token
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
scope_list = user_info.get("scope", "").split()
|
||||||
|
|
||||||
|
cred_result = await db.execute(
|
||||||
|
select(GmailCredential).where(GmailCredential.user_id == user.id)
|
||||||
|
)
|
||||||
|
existing_cred = cred_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_cred:
|
||||||
|
existing_cred.gmail_email = email # type: ignore[assignment]
|
||||||
|
existing_cred.encrypted_access_token = encrypted_access # type: ignore[assignment]
|
||||||
|
if encrypted_refresh:
|
||||||
|
existing_cred.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
|
||||||
|
existing_cred.token_expiry = token_expiry # type: ignore[assignment]
|
||||||
|
existing_cred.scopes = scope_list # type: ignore[assignment]
|
||||||
|
existing_cred.is_valid = True # type: ignore[assignment]
|
||||||
|
existing_cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
|
else:
|
||||||
|
new_cred = GmailCredential(
|
||||||
|
user_id=user.id,
|
||||||
|
gmail_email=email,
|
||||||
|
encrypted_access_token=encrypted_access,
|
||||||
|
encrypted_refresh_token=encrypted_refresh,
|
||||||
|
token_expiry=token_expiry,
|
||||||
|
scopes=scope_list,
|
||||||
|
is_valid=True,
|
||||||
|
last_verified_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(new_cred)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Gmail credentials stored for user: {email}")
|
||||||
|
except Exception as e:
|
||||||
|
# Non-fatal: login succeeds even if Gmail credential storage fails
|
||||||
|
logger.warning(
|
||||||
|
f"Could not store Gmail credentials during Google login for {email}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Create tokens
|
# Create tokens
|
||||||
tokens = oauth_service.create_tokens_for_user(user)
|
tokens = oauth_service.create_tokens_for_user(user)
|
||||||
|
|
||||||
@@ -162,16 +236,17 @@ async def google_oauth(
|
|||||||
|
|
||||||
@router.get("/google/authorize-url")
|
@router.get("/google/authorize-url")
|
||||||
async def get_google_authorize_url(redirect_uri: str):
|
async def get_google_authorize_url(redirect_uri: str):
|
||||||
"""Get Google OAuth2 authorization URL"""
|
"""Get Google OAuth2 authorization URL requesting all necessary scopes."""
|
||||||
from app.core.config import settings
|
scope = urlquote(" ".join(GOOGLE_LOGIN_SCOPES))
|
||||||
|
|
||||||
auth_url = (
|
auth_url = (
|
||||||
f"https://accounts.google.com/o/oauth2/v2/auth?"
|
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
f"client_id={settings.GOOGLE_CLIENT_ID}&"
|
f"?client_id={settings.GOOGLE_CLIENT_ID}"
|
||||||
f"response_type=code&"
|
"&response_type=code"
|
||||||
f"scope=openid%20email%20profile&"
|
f"&scope={scope}"
|
||||||
f"redirect_uri={redirect_uri}&"
|
f"&redirect_uri={redirect_uri}"
|
||||||
f"access_type=offline"
|
"&access_type=offline"
|
||||||
|
"&prompt=consent"
|
||||||
|
"&include_granted_scopes=true"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"authorization_url": auth_url}
|
return {"authorization_url": auth_url}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from urllib.parse import quote as urlquote
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -21,17 +22,18 @@ from app.models.schemas import (
|
|||||||
GmailAuthorizeResponse,
|
GmailAuthorizeResponse,
|
||||||
GmailCallbackRequest,
|
GmailCallbackRequest,
|
||||||
)
|
)
|
||||||
from app.services.gmail_service import GmailService
|
from app.services.gmail_service import GmailService, GMAIL_SCOPES
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Gmail API scopes needed for email injection
|
# Gmail API scopes requested during the "Connect Gmail" OAuth flow.
|
||||||
|
# GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from
|
||||||
|
# gmail_service so the scope list stays in sync with what GmailService uses.
|
||||||
GMAIL_API_SCOPES = [
|
GMAIL_API_SCOPES = [
|
||||||
"openid",
|
"openid",
|
||||||
"email",
|
"email",
|
||||||
"https://www.googleapis.com/auth/gmail.insert",
|
*GMAIL_SCOPES,
|
||||||
"https://www.googleapis.com/auth/gmail.labels",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Provider presets with server configurations
|
# Provider presets with server configurations
|
||||||
@@ -302,7 +304,7 @@ async def get_gmail_authorize_url(
|
|||||||
detail="Google OAuth2 is not configured on this server.",
|
detail="Google OAuth2 is not configured on this server.",
|
||||||
)
|
)
|
||||||
|
|
||||||
scope = " ".join(GMAIL_API_SCOPES)
|
scope = urlquote(" ".join(GMAIL_API_SCOPES))
|
||||||
url = (
|
url = (
|
||||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
f"?client_id={settings.GOOGLE_CLIENT_ID}"
|
f"?client_id={settings.GOOGLE_CLIENT_ID}"
|
||||||
@@ -311,6 +313,8 @@ async def get_gmail_authorize_url(
|
|||||||
f"&redirect_uri={redirect_uri}"
|
f"&redirect_uri={redirect_uri}"
|
||||||
"&access_type=offline"
|
"&access_type=offline"
|
||||||
"&prompt=consent"
|
"&prompt=consent"
|
||||||
|
"&include_granted_scopes=true"
|
||||||
|
"&state=gmail_connect"
|
||||||
)
|
)
|
||||||
return GmailAuthorizeResponse(authorization_url=url)
|
return GmailAuthorizeResponse(authorization_url=url)
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ class OAuthService:
|
|||||||
def _register_google(self):
|
def _register_google(self):
|
||||||
"""Register Google OAuth2 provider"""
|
"""Register Google OAuth2 provider"""
|
||||||
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
|
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
|
||||||
|
from app.services.gmail_service import GMAIL_SCOPES
|
||||||
|
|
||||||
|
scope = " ".join(["openid", "email", "profile", *GMAIL_SCOPES])
|
||||||
self.oauth.register(
|
self.oauth.register(
|
||||||
name="google",
|
name="google",
|
||||||
client_id=settings.GOOGLE_CLIENT_ID,
|
client_id=settings.GOOGLE_CLIENT_ID,
|
||||||
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
||||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
client_kwargs={"scope": "openid email profile"},
|
client_kwargs={"scope": scope},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_google_user_info(
|
async def get_google_user_info(
|
||||||
@@ -99,6 +102,10 @@ class OAuthService:
|
|||||||
"google_id": user_info.get("id"),
|
"google_id": user_info.get("id"),
|
||||||
"picture": user_info.get("picture"),
|
"picture": user_info.get("picture"),
|
||||||
"verified_email": user_info.get("verified_email", False),
|
"verified_email": user_info.get("verified_email", False),
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": token_data.get("refresh_token"),
|
||||||
|
"expires_in": token_data.get("expires_in"),
|
||||||
|
"scope": token_data.get("scope", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
|
|||||||
GMAIL_SCOPES = [
|
GMAIL_SCOPES = [
|
||||||
"https://www.googleapis.com/auth/gmail.insert",
|
"https://www.googleapis.com/auth/gmail.insert",
|
||||||
"https://www.googleapis.com/auth/gmail.labels",
|
"https://www.googleapis.com/auth/gmail.labels",
|
||||||
|
"https://www.googleapis.com/auth/gmail.readonly",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ Comprehensive task breakdown for repository improvements and production readines
|
|||||||
- [x] Account enable/disable toggle (UX + backend)
|
- [x] Account enable/disable toggle (UX + backend)
|
||||||
- [x] Per-user SMTP configuration (UX + backend)
|
- [x] Per-user SMTP configuration (UX + backend)
|
||||||
- [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling
|
- [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling
|
||||||
|
- [x] Unified Google OAuth flow: sign-in requests all Gmail scopes; single `/auth/callback` redirect URI needed in Google Console
|
||||||
- [x] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking)
|
- [x] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking)
|
||||||
- [ ] Implement GDPR data export endpoint
|
- [ ] Implement GDPR data export endpoint
|
||||||
- [ ] Complete notification service integration (Apprise)
|
- [ ] Complete notification service integration (Apprise)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { authApi, userApi } from '@/lib/api';
|
import { authApi, gmailApi, userApi } from '@/lib/api';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
|
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ function AuthCallbackContent() {
|
|||||||
const handleCallback = async () => {
|
const handleCallback = async () => {
|
||||||
const code = searchParams.get('code');
|
const code = searchParams.get('code');
|
||||||
const error = searchParams.get('error');
|
const error = searchParams.get('error');
|
||||||
|
const state = searchParams.get('state');
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -32,8 +33,31 @@ function AuthCallbackContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||||
|
|
||||||
|
// Gmail reconnect flow: user was already logged in and clicked
|
||||||
|
// "Connect Gmail" in Settings. The authorize URL includes state=gmail_connect.
|
||||||
|
if (state === 'gmail_connect') {
|
||||||
|
try {
|
||||||
|
await gmailApi.saveCallback(code, redirectUri);
|
||||||
|
setStatus('success');
|
||||||
|
setMessage('Gmail connected successfully! Redirecting to settings…');
|
||||||
|
setTimeout(() => router.push('/settings'), 1500);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const detail =
|
||||||
|
err instanceof Error && 'response' in err
|
||||||
|
? (err as { response?: { data?: { detail?: string } } }).response?.data
|
||||||
|
?.detail
|
||||||
|
: null;
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(detail || 'Failed to connect Gmail. Please try again.');
|
||||||
|
setTimeout(() => router.push('/settings'), 3000);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal Google Sign-In flow
|
||||||
|
try {
|
||||||
const response = await authApi.googleAuth(code, redirectUri);
|
const response = await authApi.googleAuth(code, redirectUri);
|
||||||
|
|
||||||
localStorage.setItem('access_token', response.access_token);
|
localStorage.setItem('access_token', response.access_token);
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ function SettingsContent() {
|
|||||||
|
|
||||||
const handleConnectGmail = async () => {
|
const handleConnectGmail = async () => {
|
||||||
try {
|
try {
|
||||||
const redirectUri = `${window.location.origin}/auth/gmail-callback`;
|
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||||
const url = await gmailApi.getAuthorizeUrl(redirectUri);
|
const url = await gmailApi.getAuthorizeUrl(redirectUri);
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user