feat: 마이페이지 + 프로필 수정 API 연동 (#43)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/44
This commit is contained in:
commit
b522f968ee
41
react/src/api/account.api.ts
Normal file
41
react/src/api/account.api.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
ProfileResponse,
|
||||||
|
UpdateProfileRequest,
|
||||||
|
ActivityListRequest,
|
||||||
|
ActivityListResponse,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
ChangePasswordResponse,
|
||||||
|
} from "@/features/settings/types";
|
||||||
|
|
||||||
|
/** 내 프로필 조회 */
|
||||||
|
export function fetchProfile() {
|
||||||
|
return apiClient.post<ApiResponse<ProfileResponse>>(
|
||||||
|
"/v1/in/account/profile/info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로필 수정 */
|
||||||
|
export function updateProfile(data: UpdateProfileRequest) {
|
||||||
|
return apiClient.post<ApiResponse<ProfileResponse>>(
|
||||||
|
"/v1/in/account/profile/update",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 내역 조회 */
|
||||||
|
export function fetchActivityList(data: ActivityListRequest) {
|
||||||
|
return apiClient.post<ApiResponse<ActivityListResponse>>(
|
||||||
|
"/v1/in/account/profile/activity/list",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비밀번호 변경 */
|
||||||
|
export function changePassword(data: ChangePasswordRequest) {
|
||||||
|
return apiClient.post<ApiResponse<ChangePasswordResponse>>(
|
||||||
|
"/v1/in/auth/password/change",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import PageHeader from "@/components/common/PageHeader";
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
import Pagination from "@/components/common/Pagination";
|
import Pagination from "@/components/common/Pagination";
|
||||||
|
import { fetchProfile, fetchActivityList } from "@/api/account.api";
|
||||||
|
import { formatDate, formatDateTime, formatRelativeTime } from "@/utils/format";
|
||||||
import {
|
import {
|
||||||
MOCK_PROFILE,
|
ROLE_LABELS,
|
||||||
MOCK_ACTIVITIES,
|
type UserProfile,
|
||||||
type Activity,
|
type Activity,
|
||||||
|
type ProfileResponse,
|
||||||
|
type ActivityItem,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
// 활동 타입별 아이콘 + 색상 매핑
|
// 활동 타입별 아이콘 + 색상 매핑
|
||||||
|
|
@ -21,27 +25,103 @@ const ACTIVITY_STYLE: Record<
|
||||||
|
|
||||||
const PAGE_SIZE = 5;
|
const PAGE_SIZE = 5;
|
||||||
|
|
||||||
|
/** ProfileResponse → UserProfile 뷰모델 변환 */
|
||||||
|
function mapProfile(res: ProfileResponse): UserProfile {
|
||||||
|
return {
|
||||||
|
name: res.name ?? "",
|
||||||
|
email: res.email ?? "",
|
||||||
|
role: ROLE_LABELS[res.role] ?? `역할(${res.role})`,
|
||||||
|
team: res.organization ?? "",
|
||||||
|
phone: res.phone ?? "",
|
||||||
|
joinDate: res.created_at ? formatDate(res.created_at) : "",
|
||||||
|
lastLogin: res.last_login_at ? formatDateTime(res.last_login_at) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 타입 매핑 */
|
||||||
|
function mapActivityType(type: string | null): Activity["type"] {
|
||||||
|
if (type === "send" || type === "service" || type === "device" || type === "auth") return type;
|
||||||
|
return "auth"; // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ActivityItem → Activity 뷰모델 변환 */
|
||||||
|
function mapActivity(item: ActivityItem, index: number): Activity {
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
type: mapActivityType(item.activity_type),
|
||||||
|
title: item.title ?? "",
|
||||||
|
detail: item.description ?? "",
|
||||||
|
time: item.occurred_at ? formatRelativeTime(item.occurred_at) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function MyPage() {
|
export default function MyPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const profile = MOCK_PROFILE;
|
|
||||||
|
// 프로필 상태
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
|
||||||
|
// 활동 내역 상태
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
|
// 프로필 로드
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchProfile();
|
||||||
|
setProfile(mapProfile(res.data.data));
|
||||||
|
} catch {
|
||||||
|
// 에러 시 빈 프로필 유지
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 활동 내역 로드
|
||||||
|
const loadActivities = useCallback(async (page: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetchActivityList({ page, size: PAGE_SIZE });
|
||||||
|
const data = res.data.data;
|
||||||
|
setActivities((data.items ?? []).map(mapActivity));
|
||||||
|
setTotalItems(data.pagination.total_count);
|
||||||
|
setTotalPages(data.pagination.total_pages || 1);
|
||||||
|
} catch {
|
||||||
|
setActivities([]);
|
||||||
|
setTotalItems(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadActivities(currentPage);
|
||||||
|
}, [currentPage, loadActivities]);
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 프로필 로딩 중
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="마이페이지"
|
||||||
|
description="내 계정 정보와 활동 내역을 확인하고 관리할 수 있습니다"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center py-20 text-sm text-gray-400">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const initials = profile.name
|
const initials = profile.name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((w) => w[0])
|
.map((w) => w[0])
|
||||||
.join("")
|
.join("")
|
||||||
.toUpperCase();
|
.toUpperCase() || "?";
|
||||||
|
|
||||||
// 최근 활동 페이지네이션
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const totalItems = MOCK_ACTIVITIES.length;
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
|
|
||||||
const pagedActivities = useMemo(
|
|
||||||
() =>
|
|
||||||
MOCK_ACTIVITIES.slice(
|
|
||||||
(currentPage - 1) * PAGE_SIZE,
|
|
||||||
currentPage * PAGE_SIZE,
|
|
||||||
),
|
|
||||||
[currentPage],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -154,7 +234,12 @@ export default function MyPage() {
|
||||||
<span className="text-xs text-[#64748b]">최근 7일</span>
|
<span className="text-xs text-[#64748b]">최근 7일</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{pagedActivities.map((activity) => {
|
{activities.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
|
||||||
|
활동 내역이 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activities.map((activity) => {
|
||||||
const style = ACTIVITY_STYLE[activity.type];
|
const style = ACTIVITY_STYLE[activity.type];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -183,7 +268,8 @@ export default function MyPage() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
<div className="border-t border-gray-100">
|
<div className="border-t border-gray-100">
|
||||||
|
|
@ -192,7 +278,7 @@ export default function MyPage() {
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalItems={totalItems}
|
totalItems={totalItems}
|
||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState, useMemo, useCallback } from "react";
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import PageHeader from "@/components/common/PageHeader";
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
import useShake from "@/hooks/useShake";
|
import useShake from "@/hooks/useShake";
|
||||||
import { MOCK_PROFILE } from "../types";
|
import { fetchProfile, updateProfile, changePassword } from "@/api/account.api";
|
||||||
|
|
||||||
// 비밀번호 규칙 정의
|
// 비밀번호 규칙 정의
|
||||||
const PASSWORD_RULES = [
|
const PASSWORD_RULES = [
|
||||||
|
|
@ -35,6 +35,14 @@ const STRENGTH_CONFIG = [
|
||||||
|
|
||||||
const STRENGTH_ICONS = ["", "error", "warning", "info", "check_circle"];
|
const STRENGTH_ICONS = ["", "error", "warning", "info", "check_circle"];
|
||||||
|
|
||||||
|
/** 원본 프로필 (초기화용) */
|
||||||
|
interface OriginalProfile {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
team: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfileEditPage() {
|
export default function ProfileEditPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
@ -43,9 +51,12 @@ export default function ProfileEditPage() {
|
||||||
const { triggerShake, cls } = useShake();
|
const { triggerShake, cls } = useShake();
|
||||||
|
|
||||||
// 기본 정보 폼
|
// 기본 정보 폼
|
||||||
const [name, setName] = useState(MOCK_PROFILE.name);
|
const [name, setName] = useState("");
|
||||||
const [phone, setPhone] = useState(MOCK_PROFILE.phone);
|
const [phone, setPhone] = useState("");
|
||||||
const [team, setTeam] = useState(MOCK_PROFILE.team);
|
const [team, setTeam] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [originalProfile, setOriginalProfile] = useState<OriginalProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// 비밀번호 섹션
|
// 비밀번호 섹션
|
||||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||||
|
|
@ -60,6 +71,31 @@ export default function ProfileEditPage() {
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
const [showPwModal, setShowPwModal] = useState(false);
|
const [showPwModal, setShowPwModal] = useState(false);
|
||||||
|
|
||||||
|
// 프로필 로드
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchProfile();
|
||||||
|
const data = res.data.data;
|
||||||
|
const profile: OriginalProfile = {
|
||||||
|
name: data.name ?? "",
|
||||||
|
phone: data.phone ?? "",
|
||||||
|
team: data.organization ?? "",
|
||||||
|
email: data.email ?? "",
|
||||||
|
};
|
||||||
|
setName(profile.name);
|
||||||
|
setPhone(profile.phone);
|
||||||
|
setTeam(profile.team);
|
||||||
|
setEmail(profile.email);
|
||||||
|
setOriginalProfile(profile);
|
||||||
|
} catch {
|
||||||
|
toast.error("프로필 정보를 불러오지 못했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 비밀번호 규칙 통과 여부
|
// 비밀번호 규칙 통과 여부
|
||||||
const ruleResults = useMemo(
|
const ruleResults = useMemo(
|
||||||
() => PASSWORD_RULES.map((r) => ({ ...r, pass: newPw ? r.test(newPw) : false })),
|
() => PASSWORD_RULES.map((r) => ({ ...r, pass: newPw ? r.test(newPw) : false })),
|
||||||
|
|
@ -85,26 +121,64 @@ export default function ProfileEditPage() {
|
||||||
}, [showPasswordForm]);
|
}, [showPasswordForm]);
|
||||||
|
|
||||||
// 저장 확인
|
// 저장 확인
|
||||||
const handleSaveConfirm = useCallback(() => {
|
const handleSaveConfirm = useCallback(async () => {
|
||||||
setShowSaveModal(false);
|
try {
|
||||||
|
await updateProfile({
|
||||||
|
name: name.trim(),
|
||||||
|
phone,
|
||||||
|
organization: team,
|
||||||
|
});
|
||||||
toast.success("변경사항이 저장되었습니다");
|
toast.success("변경사항이 저장되었습니다");
|
||||||
navigate("/settings");
|
navigate("/settings");
|
||||||
}, [navigate]);
|
} catch {
|
||||||
|
toast.error("프로필 수정에 실패했습니다.");
|
||||||
|
}
|
||||||
|
setShowSaveModal(false);
|
||||||
|
}, [name, phone, team, navigate]);
|
||||||
|
|
||||||
// 비밀번호 변경 확인
|
// 비밀번호 변경 확인
|
||||||
const handlePwConfirm = useCallback(() => {
|
const handlePwConfirm = useCallback(async () => {
|
||||||
setShowPwModal(false);
|
try {
|
||||||
toast.success("비밀번호가 변경되었습니다");
|
const res = await changePassword({
|
||||||
|
currentPassword: currentPw,
|
||||||
|
newPassword: newPw,
|
||||||
|
});
|
||||||
|
if (res.data.data.re_login_required) {
|
||||||
|
toast.success("비밀번호가 변경되었습니다. 다시 로그인해주세요.");
|
||||||
|
navigate("/auth/login");
|
||||||
|
} else {
|
||||||
|
toast.success("비밀번호가 변경되었습니다.");
|
||||||
navigate("/settings");
|
navigate("/settings");
|
||||||
}, [navigate]);
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("비밀번호 변경에 실패했습니다.");
|
||||||
|
}
|
||||||
|
setShowPwModal(false);
|
||||||
|
}, [currentPw, newPw, navigate]);
|
||||||
|
|
||||||
// 초기화
|
// 초기화
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
setName(MOCK_PROFILE.name);
|
if (originalProfile) {
|
||||||
setPhone(MOCK_PROFILE.phone);
|
setName(originalProfile.name);
|
||||||
setTeam(MOCK_PROFILE.team);
|
setPhone(originalProfile.phone);
|
||||||
|
setTeam(originalProfile.team);
|
||||||
|
}
|
||||||
setNameError(false);
|
setNameError(false);
|
||||||
}, []);
|
}, [originalProfile]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="프로필 수정"
|
||||||
|
description="계정 정보와 보안 설정을 수정할 수 있습니다"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center py-20 text-sm text-gray-400">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -163,7 +237,7 @@ export default function ProfileEditPage() {
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={MOCK_PROFILE.email}
|
value={email}
|
||||||
readOnly
|
readOnly
|
||||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] bg-gray-50 cursor-not-allowed focus:outline-none"
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] bg-gray-50 cursor-not-allowed focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -44,55 +44,76 @@ export interface Notification {
|
||||||
detail?: string; // 패널에서 보여줄 상세 내용
|
detail?: string; // 패널에서 보여줄 상세 내용
|
||||||
}
|
}
|
||||||
|
|
||||||
// 목데이터: 사용자 프로필
|
// --- API 응답 타입 ---
|
||||||
export const MOCK_PROFILE: UserProfile = {
|
|
||||||
name: "Admin User",
|
|
||||||
email: "admin@spms.com",
|
|
||||||
role: "관리자",
|
|
||||||
team: "Team.Stein",
|
|
||||||
phone: "010-1234-5678",
|
|
||||||
joinDate: "2025-03-15",
|
|
||||||
lastLogin: "2026-02-19 09:15",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 목데이터: 최근 활동
|
/** 프로필 응답 (ProfileResponseDto) */
|
||||||
export const MOCK_ACTIVITIES: Activity[] = [
|
export interface ProfileResponse {
|
||||||
{
|
admin_code: string | null;
|
||||||
id: 1,
|
email: string | null;
|
||||||
type: "send",
|
name: string | null;
|
||||||
title: "메시지 발송 — 마케팅 캠페인 알림",
|
phone: string | null;
|
||||||
detail: "대상: 1,234건 · 성공률 99.2%",
|
role: number;
|
||||||
time: "오늘 14:30",
|
created_at: string;
|
||||||
},
|
last_login_at: string | null;
|
||||||
{
|
organization: string | null;
|
||||||
id: 2,
|
}
|
||||||
type: "service",
|
|
||||||
title: "서비스 등록 — (주) 미래테크",
|
/** 역할 매핑 */
|
||||||
detail: "플랫폼: Android (FCM), iOS (APNs)",
|
export const ROLE_LABELS: Record<number, string> = {
|
||||||
time: "오늘 11:20",
|
1: "관리자",
|
||||||
},
|
2: "운영자",
|
||||||
{
|
} as const;
|
||||||
id: 3,
|
|
||||||
type: "device",
|
// --- API 요청 타입 ---
|
||||||
title: "인증서 갱신 — iOS Production (P8)",
|
|
||||||
detail: "만료일: 2027-02-18",
|
/** 프로필 수정 요청 */
|
||||||
time: "어제 16:45",
|
export interface UpdateProfileRequest {
|
||||||
},
|
name?: string | null;
|
||||||
{
|
phone?: string | null;
|
||||||
id: 4,
|
organization?: string | null;
|
||||||
type: "send",
|
}
|
||||||
title: "발송 실패 처리 — 긴급 공지 알림 (3건)",
|
|
||||||
detail: "원인: 유효하지 않은 토큰",
|
/** 활동 내역 요청 */
|
||||||
time: "어제 10:00",
|
export interface ActivityListRequest {
|
||||||
},
|
page: number;
|
||||||
{
|
size: number;
|
||||||
id: 5,
|
from?: string | null;
|
||||||
type: "auth",
|
to?: string | null;
|
||||||
title: "로그인",
|
}
|
||||||
detail: "IP: 192.168.1.100 · Chrome 122 / macOS",
|
|
||||||
time: "2월 17일 09:05",
|
/** 활동 내역 항목 (ActivityItemDto) */
|
||||||
},
|
export interface ActivityItem {
|
||||||
];
|
activity_type: string | null;
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
occurred_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 내역 페이지네이션 */
|
||||||
|
export interface ActivityPagination {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
total_count: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활동 내역 응답 */
|
||||||
|
export interface ActivityListResponse {
|
||||||
|
items: ActivityItem[] | null;
|
||||||
|
pagination: ActivityPagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비밀번호 변경 요청 */
|
||||||
|
export interface ChangePasswordRequest {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비밀번호 변경 응답 */
|
||||||
|
export interface ChangePasswordResponse {
|
||||||
|
re_login_required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// 목데이터: 알림
|
// 목데이터: 알림
|
||||||
export const MOCK_NOTIFICATIONS: Notification[] = [
|
export const MOCK_NOTIFICATIONS: Notification[] = [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user