diff --git a/src/app/auth/google/callback/page.tsx b/src/app/auth/google/callback/page.tsx new file mode 100644 index 0000000..c57a32c --- /dev/null +++ b/src/app/auth/google/callback/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import GoogleAuthCallback from "@/views/GoogleAuthCallback"; + +export const metadata: Metadata = { + title: "ادامه ورود با گوگل", + robots: { index: false, follow: false }, +}; + +export default function GoogleAuthCallbackPage() { + return ; +} diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index 14fcc6a..041f09e 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -27,7 +27,7 @@ export default function MobileBottomNav() { const pathname = usePathname() || "/"; const { isAuthenticated } = useAuth(); - if (pathname.startsWith("/admin") || pathname === "/logout") { + if (pathname.startsWith("/admin") || pathname === "/logout" || pathname.startsWith("/auth/google")) { return null; } diff --git a/src/components/MobileVerificationGate.tsx b/src/components/MobileVerificationGate.tsx new file mode 100644 index 0000000..5342547 --- /dev/null +++ b/src/components/MobileVerificationGate.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, LogOut, ShieldCheck, Smartphone } from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/lib/api"; +import OtpCodeField from "@/components/OtpCodeField"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { resolveErrorMessage } from "@/lib/utils"; + +const toEnglishDigits = (value: string) => + value + .replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0)) + .replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660)) + .replace(/[^\d]/g, ""); + +export default function MobileVerificationGate() { + const { user, isAuthenticated, loading, refreshProfile, setUser, logout } = useAuth(); + const { toast } = useToast(); + const [mobile, setMobile] = useState(""); + const [code, setCode] = useState(""); + const [step, setStep] = useState<"collect" | "verify">("collect"); + const [sending, setSending] = useState(false); + const [verifying, setVerifying] = useState(false); + const [cooldown, setCooldown] = useState(0); + + useEffect(() => { + if (user?.mobile) { + setMobile(user.mobile); + setStep("verify"); + } + }, [user?.mobile]); + + useEffect(() => { + if (cooldown <= 0) { + return; + } + const timer = window.setTimeout(() => setCooldown((current) => current - 1), 1000); + return () => window.clearTimeout(timer); + }, [cooldown]); + + const handleSendOtp = async () => { + try { + setSending(true); + const normalizedMobile = toEnglishDigits(mobile); + const response = await api.sendMobileVerificationOtp({ mobile: normalizedMobile }); + setMobile(normalizedMobile); + setStep("verify"); + setCooldown(Math.min(response.expires_in_seconds, 120)); + toast({ + title: "کد تأیید ارسال شد", + description: response.message, + variant: "success", + }); + } catch (error: unknown) { + toast({ + title: "خطا در ارسال کد", + description: resolveErrorMessage(error, "ارسال کد تأیید انجام نشد."), + variant: "destructive", + }); + } finally { + setSending(false); + } + }; + + const handleVerify = async () => { + try { + setVerifying(true); + const profile = await api.verifyMobile({ + mobile: toEnglishDigits(mobile), + code: toEnglishDigits(code), + }); + setUser(profile); + await refreshProfile(); + toast({ + title: "شماره موبایل تأیید شد", + description: "از این پس می‌توانید با موبایل و کد پیامکی وارد شوید.", + variant: "success", + }); + } catch (error: unknown) { + toast({ + title: "کد نامعتبر است", + description: resolveErrorMessage(error, "تأیید شماره موبایل انجام نشد."), + variant: "destructive", + }); + } finally { + setVerifying(false); + } + }; + + if (loading || !isAuthenticated || !user?.requires_mobile_verification) { + return null; + } + + return ( +
+
+
+ +
+
+ +
+

تکمیل امنیت حساب با موبایل

+

+ برای ادامه استفاده از سایت باید شماره موبایل خود را ثبت و با کد پیامکی تأیید کنید. + ورودهای بعدی و بازیابی حساب شما از همین مسیر انجام می‌شود. +

+
+
+ +
+
+
+

{user.first_name || user.last_name ? `${user.first_name} ${user.last_name}`.trim() : user.username}

+

+ {user.email ? `ایمیل متصل: ${user.email}` : "ایمیل برای این حساب اختیاری است."} +

+
+
+ +
+
+ +
+
+ + setMobile(toEnglishDigits(event.target.value))} + placeholder="09xxxxxxxxx" + className="h-12 rounded-2xl" + disabled={sending || verifying} + /> +
+ + {step === "verify" ? ( +
+
+ + setCode(toEnglishDigits(value))} + disabled={verifying} + /> +
+
+ + +
+
+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 666a7e0..9a3cbfc 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -5,6 +5,7 @@ import { useMemo } from "react"; import { Link, NavLink } from "@/lib/router"; import { useAuth } from "@/contexts/AuthContext"; import ModeToggle from "@/components/ModeToggle"; +import NotificationsBell from "@/components/NotificationsBell"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -70,6 +71,7 @@ export default function Navbar() { بلاگ رویدادها + {isAuthenticated ? : null} {isAuthenticated ? (
+ {isAuthenticated ? : null}
diff --git a/src/components/NotificationsBell.tsx b/src/components/NotificationsBell.tsx new file mode 100644 index 0000000..636013f --- /dev/null +++ b/src/components/NotificationsBell.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useNotifications } from "@/contexts/NotificationsContext"; +import type { NotificationSchema } from "@/lib/types"; +import { cn, formatJalali } from "@/lib/utils"; + +const connectionLabels = { + idle: "خاموش", + connecting: "در حال اتصال", + connected: "متصل", + disconnected: "قطع شده", +} as const; + +function NotificationItem({ + notification, + onOpen, + onDelete, +}: { + notification: NotificationSchema; + onOpen: (notification: NotificationSchema) => Promise; + onDelete: (notification: NotificationSchema) => Promise; +}) { + return ( +
+
+ + +
+
+ ); +} + +export default function NotificationsBell() { + const { + notifications, + unreadCount, + totalCount, + hasMore, + isLoading, + isLoadingMore, + connectionStatus, + loadMore, + markAllAsSeen, + deleteNotification, + openNotification, + } = useNotifications(); + + return ( + + + + + +
+
+
+ + {connectionLabels[connectionStatus]} + + {unreadCount > 0 ? ( + + ) : null} +
+
+

اعلان‌ها

+

+ {totalCount > 0 ? `${totalCount} مورد ثبت شده` : "هنوز اعلانی ندارید."} +

+
+
+
+ + +
+ {isLoading ? ( +
+ + در حال بارگذاری اعلان‌ها... +
+ ) : notifications.length ? ( + notifications.map((notification) => ( + + )) + ) : ( +
+ اعلان تازه‌ای برای شما ثبت نشده است. +
+ )} +
+
+ + {hasMore ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/src/components/OtpCodeField.tsx b/src/components/OtpCodeField.tsx new file mode 100644 index 0000000..e3a66b9 --- /dev/null +++ b/src/components/OtpCodeField.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; +import { cn } from "@/lib/utils"; + +type OtpCodeFieldProps = { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + length?: number; + className?: string; +}; + +export default function OtpCodeField({ + value, + onChange, + disabled = false, + length = 5, + className, +}: OtpCodeFieldProps) { + return ( + onChange(nextValue.replace(/[^\d]/g, ""))} + disabled={disabled} + inputMode="numeric" + containerClassName={cn("w-full justify-center", className)} + > + + {Array.from({ length }).map((_, index) => ( + + ))} + + + ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 915e5d9..8042e11 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -6,7 +6,9 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { ThemeProvider } from "@/components/ThemeProvider"; +import MobileVerificationGate from "@/components/MobileVerificationGate"; import { AuthProvider } from "@/contexts/AuthContext"; +import { NotificationsProvider } from "@/contexts/NotificationsContext"; export default function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = React.useState(() => new QueryClient()); @@ -20,11 +22,14 @@ export default function Providers({ children }: { children: React.ReactNode }) { > - - - - {children} - + + + + + {children} + + + diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 92ec642..6a62370 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; import { api } from '@/lib/api'; import type { UserProfileSchema } from '@/lib/types'; @@ -7,47 +7,83 @@ type User = UserProfileSchema; interface AuthContextType { user: User | null; loading: boolean; - login: (email: string, password: string) => Promise; + login: (identifier: string, password: string) => Promise; + loginWithOtp: (mobile: string, code: string) => Promise; + setSessionTokens: (accessToken: string, refreshToken: string) => Promise; + refreshProfile: () => Promise; + setUser: (user: User | null) => void; logout: () => void; isAuthenticated: boolean; } const AuthContext = createContext(undefined); +const ACCESS_TOKEN_KEY = 'access_token'; +const REFRESH_TOKEN_KEY = 'refresh_token'; + export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - useEffect(() => { - checkAuth(); + const clearSession = useCallback(() => { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + setUser(null); }, []); - const checkAuth = async () => { - const token = localStorage.getItem('access_token'); - if (token) { - try { - const profile = await api.getProfile(); - setUser(profile as User); - } catch (error) { - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - } + const refreshProfile = useCallback(async () => { + const token = localStorage.getItem(ACCESS_TOKEN_KEY); + if (!token) { + setUser(null); + setLoading(false); + return null; } - setLoading(false); - }; - const login = async (email: string, password: string) => { - const response = await api.login({ email, password }); - localStorage.setItem('access_token', response.access_token); - localStorage.setItem('refresh_token', response.refresh_token); - await checkAuth(); - }; + try { + const profile = await api.getProfile(); + setUser(profile as User); + return profile as User; + } catch { + clearSession(); + return null; + } finally { + setLoading(false); + } + }, [clearSession]); - const logout = () => { - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - setUser(null); - }; + useEffect(() => { + void refreshProfile(); + }, [refreshProfile]); + + const setSessionTokens = useCallback( + async (accessToken: string, refreshToken: string) => { + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + setLoading(true); + await refreshProfile(); + }, + [refreshProfile], + ); + + const login = useCallback( + async (identifier: string, password: string) => { + const response = await api.login({ identifier, password }); + await setSessionTokens(response.access_token, response.refresh_token); + }, + [setSessionTokens], + ); + + const loginWithOtp = useCallback( + async (mobile: string, code: string) => { + const response = await api.loginWithOtp({ mobile, code }); + await setSessionTokens(response.access_token, response.refresh_token); + }, + [setSessionTokens], + ); + + const logout = useCallback(() => { + clearSession(); + }, [clearSession]); return ( Promise; + loadMore: () => Promise; + markAsSeen: (notification: NotificationSchema) => Promise; + deleteNotification: (notification: NotificationSchema) => Promise; + markAllAsSeen: () => Promise; + openNotification: (notification: NotificationSchema) => Promise; +}; + +const NotificationsContext = createContext(undefined); +const PAGE_SIZE = 12; + +const mergeNotifications = ( + current: NotificationSchema[], + incoming: NotificationSchema[], +) => { + const byId = new Map(); + for (const notification of current) { + byId.set(notification.id, notification); + } + for (const notification of incoming) { + const existing = byId.get(notification.id); + byId.set(notification.id, existing ? { ...existing, ...notification } : notification); + } + return Array.from(byId.values()).sort((left, right) => { + return new Date(right.created_at).getTime() - new Date(left.created_at).getTime(); + }); +}; + +const openNotificationTarget = (notification: NotificationSchema) => { + if (typeof window === "undefined" || !notification.action_url) { + return; + } + + const targetUrl = notification.action_url; + if (/^https?:\/\//i.test(targetUrl)) { + window.open(targetUrl, "_blank", "noopener,noreferrer"); + return; + } + window.location.assign(targetUrl); +}; + +export function NotificationsProvider({ children }: { children: ReactNode }) { + const { isAuthenticated } = useAuth(); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [totalCount, setTotalCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [connectionStatus, setConnectionStatus] = useState("idle"); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const shownToastIdsRef = useRef>(new Set()); + + const hasMore = notifications.length < totalCount; + + const cleanupStream = useCallback(() => { + if (reconnectTimeoutRef.current !== null) { + window.clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }, []); + + const applyNotificationList = useCallback((payload: NotificationListSchema) => { + setNotifications(payload.notifications); + setUnreadCount(payload.unread_count); + setTotalCount(payload.count); + }, []); + + const refreshNotifications = useCallback(async () => { + if (!isAuthenticated) { + setNotifications([]); + setUnreadCount(0); + setTotalCount(0); + setConnectionStatus("idle"); + return; + } + + setIsLoading(true); + try { + const payload = await api.getNotifications({ limit: PAGE_SIZE, offset: 0 }); + applyNotificationList(payload); + } finally { + setIsLoading(false); + } + }, [applyNotificationList, isAuthenticated]); + + const loadMore = useCallback(async () => { + if (!isAuthenticated || isLoadingMore || !hasMore) { + return; + } + + setIsLoadingMore(true); + try { + const payload = await api.getNotifications({ + limit: PAGE_SIZE, + offset: notifications.length, + }); + setNotifications((current) => mergeNotifications(current, payload.notifications)); + setUnreadCount(payload.unread_count); + setTotalCount(payload.count); + } finally { + setIsLoadingMore(false); + } + }, [hasMore, isAuthenticated, isLoadingMore, notifications.length]); + + const markAsSeen = useCallback(async (notification: NotificationSchema) => { + if (notification.is_seen) { + return null; + } + + const payload = await api.markNotificationSeen(notification.id); + if (payload.deleted) { + setNotifications((current) => current.filter((item) => item.id !== notification.id)); + setTotalCount((current) => Math.max(current - 1, 0)); + } else { + setNotifications((current) => + current.map((item) => + item.id === notification.id ? { ...item, is_seen: true } : item, + ), + ); + } + if (typeof payload.unread_count === "number") { + setUnreadCount(payload.unread_count); + } else { + setUnreadCount((current) => Math.max(current - 1, 0)); + } + return payload; + }, []); + + const deleteNotification = useCallback(async (notification: NotificationSchema) => { + const payload = await api.deleteNotification(notification.id); + setNotifications((current) => current.filter((item) => item.id !== notification.id)); + setTotalCount((current) => Math.max(current - 1, 0)); + if (typeof payload.unread_count === "number") { + setUnreadCount(payload.unread_count); + } else if (!notification.is_seen) { + setUnreadCount((current) => Math.max(current - 1, 0)); + } + return payload; + }, []); + + const markAllAsSeen = useCallback(async () => { + if (!isAuthenticated) { + return; + } + await api.markAllNotificationsRead(); + setUnreadCount(0); + setNotifications((current) => + current.map((notification) => ({ ...notification, is_seen: true })), + ); + }, [isAuthenticated]); + + const openNotification = useCallback(async (notification: NotificationSchema) => { + await markAsSeen(notification); + openNotificationTarget(notification); + }, [markAsSeen]); + + const announceIncomingNotification = useCallback((notification: NotificationSchema) => { + if (notification.is_seen || shownToastIdsRef.current.has(notification.id)) { + return; + } + shownToastIdsRef.current.add(notification.id); + toast({ + title: notification.title, + description: notification.message, + variant: + notification.level === "error" + ? "destructive" + : notification.level === "success" + ? "success" + : "default", + }); + }, []); + + const connectToStream = useCallback(async () => { + if (!isAuthenticated) { + cleanupStream(); + setConnectionStatus("idle"); + return; + } + + cleanupStream(); + setConnectionStatus("connecting"); + try { + const tokenPayload = await api.issueNotificationStreamToken(); + const stream = new EventSource(api.buildNotificationStreamUrl(tokenPayload.token)); + eventSourceRef.current = stream; + + stream.onopen = () => { + reconnectAttemptRef.current = 0; + setConnectionStatus("connected"); + }; + + stream.addEventListener("connected", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + notifications?: NotificationSchema[]; + unread_count?: number; + }; + if (Array.isArray(payload.notifications)) { + setNotifications((current) => mergeNotifications(current, payload.notifications || [])); + setTotalCount((current) => Math.max(current, payload.notifications?.length || 0)); + } + if (typeof payload.unread_count === "number") { + setUnreadCount(payload.unread_count); + } + }); + + stream.addEventListener("notification", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + notification?: NotificationSchema; + unread_count?: number; + }; + const incomingNotification = payload.notification; + if (!incomingNotification) { + return; + } + setNotifications((current) => { + const exists = current.some((item) => item.id === incomingNotification.id); + if (!exists) { + setTotalCount((count) => count + 1); + } + return mergeNotifications(current, [incomingNotification]); + }); + if (typeof payload.unread_count === "number") { + setUnreadCount(payload.unread_count); + } + announceIncomingNotification(incomingNotification); + }); + + stream.addEventListener("notification_seen", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + notification_id?: string; + notification?: NotificationSchema | null; + deleted?: boolean; + unread_count?: number; + }; + if (payload.deleted && payload.notification_id) { + setNotifications((current) => + current.filter((item) => item.id !== payload.notification_id), + ); + setTotalCount((current) => Math.max(current - 1, 0)); + } else if (payload.notification) { + setNotifications((current) => mergeNotifications(current, [payload.notification!])); + } else if (payload.notification_id) { + setNotifications((current) => + current.map((item) => + item.id === payload.notification_id ? { ...item, is_seen: true } : item, + ), + ); + } + if (typeof payload.unread_count === "number") { + setUnreadCount(payload.unread_count); + } + }); + + stream.addEventListener("notification_mark_all_read", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + unread_count?: number; + }; + setNotifications((current) => + current.map((notification) => ({ ...notification, is_seen: true })), + ); + if (typeof payload.unread_count === "number") { + setUnreadCount(payload.unread_count); + } + }); + + stream.addEventListener("unread_count", (event) => { + const payload = JSON.parse((event as MessageEvent).data) as { + unread_count?: number; + }; + if (typeof payload.unread_count === "number") { + setUnreadCount(payload.unread_count); + } + }); + + stream.onerror = () => { + stream.close(); + eventSourceRef.current = null; + setConnectionStatus("disconnected"); + reconnectAttemptRef.current += 1; + const delay = Math.min(1000 * 2 ** reconnectAttemptRef.current, 30000); + reconnectTimeoutRef.current = window.setTimeout(() => { + void connectToStream(); + }, delay); + }; + } catch { + setConnectionStatus("disconnected"); + } + }, [announceIncomingNotification, cleanupStream, isAuthenticated]); + + useEffect(() => { + if (!isAuthenticated) { + cleanupStream(); + setNotifications([]); + setUnreadCount(0); + setTotalCount(0); + setConnectionStatus("idle"); + return; + } + + void refreshNotifications(); + void connectToStream(); + + return () => { + cleanupStream(); + }; + }, [cleanupStream, connectToStream, isAuthenticated, refreshNotifications]); + + const value = useMemo(() => ({ + notifications, + unreadCount, + totalCount, + hasMore, + isLoading, + isLoadingMore, + connectionStatus, + refreshNotifications, + loadMore, + markAsSeen, + deleteNotification, + markAllAsSeen, + openNotification, + }), [ + connectionStatus, + deleteNotification, + hasMore, + isLoading, + isLoadingMore, + loadMore, + markAllAsSeen, + markAsSeen, + notifications, + openNotification, + refreshNotifications, + totalCount, + unreadCount, + ]); + + return ( + + {children} + + ); +} + +export function useNotifications() { + const context = useContext(NotificationsContext); + if (!context) { + throw new Error("useNotifications must be used within a NotificationsProvider"); + } + return context; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 70beab9..730ee26 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -179,6 +179,27 @@ class ApiClient { }); } + async loginWithOtp(data: Types.UserOtpLoginSchema) { + return this.request('/api/auth/login/otp', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async sendOtp(data: Types.OtpSendSchema) { + return this.request('/api/auth/otp/send', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async verifyRegisterOtp(data: Types.RegisterOtpVerifySchema) { + return this.request('/api/auth/otp/verify-register', { + method: 'POST', + body: JSON.stringify(data), + }); + } + async refreshToken(data: Types.TokenRefreshIn) { return this.request('/api/auth/refresh', { method: 'POST', @@ -186,6 +207,60 @@ class ApiClient { }); } + async resetPassword(data: Types.PasswordResetSchema) { + return this.request('/api/auth/reset-password', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async sendMobileVerificationOtp(data: Types.MobileOtpSendSchema) { + return this.request('/api/auth/mobile/send-otp', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async verifyMobile(data: Types.MobileOtpVerifySchema) { + return this.request('/api/auth/mobile/verify', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async startGoogleLogin() { + if (typeof window !== 'undefined') { + window.location.href = `${this.baseUrl}/api/auth/oauth/google/start`; + } + } + + async getGoogleFlow(flow: string) { + return this.request( + `/api/auth/oauth/google/flow?flow=${encodeURIComponent(flow)}` + ); + } + + async completeGoogleSignup(data: Types.GoogleCompleteSchema) { + return this.request('/api/auth/oauth/google/complete', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async resendGoogleClaimOtp(flow: string) { + return this.request('/api/auth/oauth/google/claim/send-otp', { + method: 'POST', + body: JSON.stringify({ flow }), + }); + } + + async verifyGoogleClaim(flow: string, code: string) { + return this.request('/api/auth/oauth/google/claim/verify', { + method: 'POST', + body: JSON.stringify({ flow, code }), + }); + } + async verifyEmail(token: string): Promise { const url = `${this.baseUrl}/api/auth/verify-email/${encodeURIComponent(token)}`; const response = await fetch(url, { method: 'GET' }); @@ -265,6 +340,17 @@ class ApiClient { }); } + async getLegacyVerifyEmailMessage(token: string) { + return this.request(`/api/auth/verify-email/${encodeURIComponent(token)}`); + } + + async getLegacyResetTokenMessage(token: string) { + return this.request('/api/auth/reset-password-confirm', { + method: 'POST', + body: JSON.stringify({ token }), + }); + } + async checkUsername(username: string) { return this.request( @@ -272,6 +358,12 @@ class ApiClient { ); } + async checkMobile(mobile: string) { + return this.request( + `/api/auth/check-mobile?mobile=${encodeURIComponent(mobile)}` + ); + } + // Admin auth endpoints async listDeletedUsers() { return this.request('/api/auth/users/deleted'); @@ -681,6 +773,47 @@ class ApiClient { } ); } + + async getNotifications(params?: { limit?: number; offset?: number; type?: string }) { + const query = new URLSearchParams(); + if (params?.limit != null) query.set('limit', String(params.limit)); + if (params?.offset != null) query.set('offset', String(params.offset)); + if (params?.type) query.set('type', params.type); + return this.request( + `/api/notifications/${query.toString() ? `?${query.toString()}` : ''}` + ); + } + + async markNotificationSeen(id: string) { + return this.request('/api/notifications/mark-seen', { + method: 'POST', + body: JSON.stringify({ id }), + }); + } + + async deleteNotification(id: string) { + return this.request(`/api/notifications/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + } + + async markAllNotificationsRead(type?: string) { + const query = type ? `?type=${encodeURIComponent(type)}` : ''; + return this.request<{ marked_read: number }>(`/api/notifications/mark-all-read${query}`, { + method: 'POST', + }); + } + + async issueNotificationStreamToken() { + return this.request('/api/notifications/stream-token', { + method: 'POST', + }); + } + + buildNotificationStreamUrl(token: string) { + const cleanBaseUrl = this.baseUrl.replace(/\/+$/, ''); + return `${cleanBaseUrl}/api/notifications/stream/?token=${encodeURIComponent(token)}`; + } } export const api = new ApiClient(API_BASE_URL); diff --git a/src/lib/types.ts b/src/lib/types.ts index 9bffb0f..c463325 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,6 +12,7 @@ export interface ErrorSchema { export interface TokenSchema { access_token: string; refresh_token: string; + token_type?: string; } export interface MajorOption { @@ -21,7 +22,8 @@ export interface MajorOption { export interface UserProfileSchema { id: number; - email: string; + email?: string | null; + mobile?: string | null; username: string; first_name: string; last_name: string; @@ -36,6 +38,9 @@ export interface UserProfileSchema { date_joined: string; is_email_verified?: boolean; + is_mobile_verified?: boolean; + requires_mobile_verification?: boolean; + has_google_link?: boolean; is_active?: boolean; is_staff?: boolean; is_superuser?: boolean; @@ -47,7 +52,8 @@ export interface UserProfileSchema { export interface UserListSchema { id: number; username: string; - email: string; + email?: string | null; + mobile?: string | null; first_name: string; last_name: string; full_name?: string | null; @@ -60,11 +66,13 @@ export interface UserListSchema { } export interface UserRegistrationSchema { - email: string; + mobile: string; + code: string; password: string; username: string; - first_name: string; - last_name: string; + email?: string | null; + first_name?: string | null; + last_name?: string | null; student_id?: string | null; year_of_study?: number | null; major?: string | null; @@ -72,6 +80,7 @@ export interface UserRegistrationSchema { } export type UserUpdateSchema = { + email?: string | null; first_name?: string | null; last_name?: string | null; bio?: string | null; @@ -83,25 +92,123 @@ export type UserUpdateSchema = { export interface UserLoginSchema { - email: string; + identifier: string; password: string; } +export interface UserOtpLoginSchema { + mobile: string; + code: string; +} + +export interface RegisterOtpVerifySchema { + mobile: string; + code: string; +} + +export interface OtpSendSchema { + mobile: string; + mode: "register" | "login" | "reset_password" | "verify_mobile" | "google_claim"; +} + +export interface OtpSendResponseSchema { + message: string; + expires_in_seconds: number; + expires_at: string; +} + export interface TokenRefreshIn { refresh_token: string; } export interface UsernameCheckSchema { - available: boolean; + exists: boolean; } -export interface PasswordResetRequestSchema { - email: string; +export interface MobileLookupSchema { + exists: boolean; + has_password: boolean; } -export interface PasswordResetConfirmSchema { +export interface PasswordResetSchema { + mobile: string; + code: string; + new_password: string; +} + +export interface MobileOtpSendSchema { + mobile: string; +} + +export interface MobileOtpVerifySchema { + mobile: string; + code: string; +} + +export interface GoogleFlowResponseSchema { + status: "authenticated" | "collect_profile" | "claim_required" | "error"; + email?: string | null; + first_name?: string | null; + last_name?: string | null; + avatar_url?: string | null; + resolution?: "new_account" | "existing_email_claim" | "existing_mobile_claim" | null; + mobile?: string | null; + mobile_hint?: string | null; + detail?: string | null; + access_token?: string | null; + refresh_token?: string | null; +} + +export interface GoogleCompleteSchema { + flow: string; + mobile: string; + username?: string | null; + student_id?: string | null; + year_of_study?: number | null; + major?: string | null; + university?: string | null; + first_name?: string | null; + last_name?: string | null; +} + +export interface NotificationSchema { + id: string; + type: string; + title: string; + message: string; + level: "info" | "success" | "warning" | "error"; + created_at: string; + is_seen: boolean; + delete_on_seen: boolean; + action_url?: string | null; + entity_type?: string | null; + entity_id?: string | number | null; + meta?: Record; +} + +export interface NotificationListSchema { + count: number; + unread_count: number; + notifications: NotificationSchema[]; +} + +export interface NotificationSeenResponseSchema { + marked_read: boolean; + notification_id?: string | null; + deleted?: boolean; + notification?: NotificationSchema | null; + unread_count?: number | null; +} + +export interface NotificationDeleteResponseSchema { + deleted: boolean; + notification_id?: string | null; + unread_count?: number | null; +} + +export interface NotificationStreamTokenResponseSchema { token: string; - password: string; + expires_in: number; } // Blog Types diff --git a/src/views/Auth.tsx b/src/views/Auth.tsx index fede829..d63b5f4 100644 --- a/src/views/Auth.tsx +++ b/src/views/Auth.tsx @@ -1,551 +1,768 @@ "use client"; -import { useEffect, useState, useMemo } from 'react'; -import { Helmet } from '@/lib/helmet'; -import { Link, useNavigate } from '@/lib/router'; -import { useAuth } from '@/contexts/AuthContext'; -import { useQuery } from '@tanstack/react-query'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import SearchableCombobox from '@/components/SearchableCombobox' -import { useToast } from '@/hooks/use-toast'; -import { api } from '@/lib/api'; -import { resolveErrorMessage } from '@/lib/utils'; +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowRight, + KeyRound, + Loader2, + MessageSquareMore, + Smartphone, +} from "lucide-react"; +import SearchableCombobox from "@/components/SearchableCombobox"; +import OtpCodeField from "@/components/OtpCodeField"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useAuth } from "@/contexts/AuthContext"; +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/lib/api"; +import { Link, Navigate, useNavigate } from "@/lib/router"; +import { resolveErrorMessage } from "@/lib/utils"; -type RegisterErrors = { - email?: string; - username?: string; - password?: string; - first_name?: string; - last_name?: string; - university?: string; -}; +type AuthStep = "mobile" | "password" | "otp_login" | "otp_register" | "register_details"; -const MIN_PASSWORD_LENGTH = 8; // ← در صورت نیاز تغییر بده -const USERNAME_REGEX = /^[A-Za-z0-9._-]{3,30}$/; // ← کاراکترهای مجاز + حداقل 3 کاراکتر -const DISALLOW_PERSIAN_OR_SPACE = /[\u0600-\u06FF\s]/g; // ← حروف فارسی + فاصله +const OTP_LENGTH = 5; +const USERNAME_REGEX = /^[A-Za-z0-9._-]{3,30}$/; -const sanitizeUsername = (v: string) => v.replace(/[^A-Za-z0-9._-]/g, ''); -const sanitizeNoFaNoSpace = (v: string) => v.replace(DISALLOW_PERSIAN_OR_SPACE, ''); +const sanitizeUsername = (value: string) => value.replace(/[^A-Za-z0-9._-]/g, ""); +const sanitizeText = (value: string) => value.trim(); +const normalizeDigits = (value: string) => + value + .replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0)) + .replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660)); +const sanitizeMobile = (value: string) => normalizeDigits(value).replace(/[^\d]/g, ""); -const isValidEmailBasic = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); +const createEmptyRegisterForm = () => ({ + username: "", + email: "", + password: "", + first_name: "", + last_name: "", + student_id: "", + year_of_study: "", + major: null as string | null, + university: null as string | null, +}); + +function GoogleIcon() { + return ( + + ); +} export default function Auth() { const navigate = useNavigate(); - const { login } = useAuth(); const { toast } = useToast(); - const [loading, setLoading] = useState(false); - const [unverified, setUnverified] = useState(false); - const [resendLoading, setResendLoading] = useState(false); + const { login, loginWithOtp, isAuthenticated } = useAuth(); + const [step, setStep] = useState("mobile"); + const [authLoading, setAuthLoading] = useState(false); + const [mobile, setMobile] = useState(""); + const [password, setPassword] = useState(""); + const [otpCode, setOtpCode] = useState(""); + const [lookupState, setLookupState] = useState<{ exists: boolean; has_password: boolean } | null>(null); + const [otpCooldowns, setOtpCooldowns] = useState>({ + login: 0, + register: 0, + }); + const [registerForm, setRegisterForm] = useState(createEmptyRegisterForm); - const initialLogin = { email: '', password: '' }; - const initialRegister = { - email: '', - password: '', - username: '', - first_name: '', - last_name: '', - student_id: '', - year_of_study: '', - major: null as string | null, - university: null as string | null, - }; + useEffect(() => { + const timer = window.setInterval(() => { + setOtpCooldowns((current) => ({ + login: Math.max(current.login - 1, 0), + register: Math.max(current.register - 1, 0), + })); + }, 1000); + return () => window.clearInterval(timer); + }, []); - const [loginData, setLoginData] = useState(initialLogin); - const [registerData, setRegisterData] = useState(initialRegister); - const [regErrors, setRegErrors] = useState({}); - const [tab, setTab] = useState<'login' | 'register'>('login'); - - const siteUrl = 'https://east-guilan-ce.ir'; - const siteName = 'انجمن علمی کامپیوتر شرق دانشگاه گیلان'; - const canonicalUrl = `${siteUrl}/auth`; - const ogImage = `${siteUrl}/favicon.ico`; - const metaRobots = 'noindex, nofollow'; - - const { pageTitle, pageDescription } = useMemo(() => { - const variant = tab === 'register' ? 'ثبت‌نام' : 'ورود'; - const description = - tab === 'register' - ? 'برای پیوستن به رویدادها، کارگاه‌ها و برنامه‌های انجمن علمی کامپیوتر شرق گیلان حساب کاربری بسازید.' - : 'برای مدیریت پروفایل و ثبت‌نام‌ رویدادها وارد انجمن علمی کامپیوتر شرق گیلان شوید.'; - return { - pageTitle: `${variant} | ${siteName}`, - pageDescription: description, - }; - }, [tab, siteName]); - - const { data: majors, isLoading: majorsLoading } = useQuery({ - queryKey: ['majors'], - queryFn: () => api.getMajors(), // expects [{ code, label }] + const { data: majors = [], isLoading: majorsLoading } = useQuery({ + queryKey: ["majors"], + queryFn: () => api.getMajors(), + staleTime: 7 * 24 * 60 * 60 * 1000, + }); + const { data: universities = [], isLoading: universitiesLoading } = useQuery({ + queryKey: ["universities"], + queryFn: () => api.getUniversities(), staleTime: 7 * 24 * 60 * 60 * 1000, }); - const { data: universities, isLoading: universitiesLoading } = useQuery({ - queryKey: ['universities'], - queryFn: () => api.getUniversities(), // expects [{ code, label }] - staleTime: 7 * 24 * 60 * 60 * 1000, - }); - - const majorItems = useMemo( - () => (majors ?? []).map((m) => ({ value: String(m.code), label: m.label })), - [majors] + const majorItems = useMemo( + () => majors.map((major) => ({ value: String(major.code), label: major.label })), + [majors], ); const universityItems = useMemo( - () => (universities ?? []).map((u) => ({ value: String(u.code), label: u.label })), - [universities] + () => universities.map((university) => ({ value: String(university.code), label: university.label })), + [universities], ); - // تبدیل ارقام فارسی/عربی به انگلیسی و حذف هرچیز غیر 0-9 - const toEnglishDigits = (v: string) => - v - .replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - 0x06F0)) // Persian ۰-۹ - .replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660)); // Arabic ٠-٩ + const stepMeta = useMemo(() => { + switch (step) { + case "password": + return { + title: "رمز عبور حساب", + description: "برای این شماره موبایل حساب فعال پیدا شد. رمز عبور را وارد کنید یا روش ورود را به کد پیامکی تغییر دهید.", + }; + case "otp_login": + return { + title: "ورود با کد پیامکی", + description: "کد ۵ رقمی ارسال‌شده به موبایل را وارد کنید تا وارد حساب خود شوید.", + }; + case "otp_register": + return { + title: "تایید موبایل", + description: "ابتدا موبایل شما با کد پیامکی تایید می‌شود، سپس فرم تکمیل اطلاعات نمایش داده خواهد شد.", + }; + case "register_details": + return { + title: "تکمیل اطلاعات حساب", + description: "موبایل شما تایید شد. حالا اطلاعات تکمیلی و رمز عبور را وارد کنید تا ثبت‌نام نهایی شود.", + }; + default: + return { + title: "ورود و ثبت‌نام", + description: "ابتدا شماره موبایل را وارد کنید تا مسیر مناسب برای ورود یا ثبت‌نام به شما نمایش داده شود.", + }; + } + }, [step]); - const onlyAsciiDigits = (v: string) => toEnglishDigits(v).replace(/[^0-9]/g, ''); + if (isAuthenticated) { + return ; + } - const handleResendVerification = async () => { - const email = sanitizeNoFaNoSpace(loginData.email.trim()); - if (!email) { + const updateRegisterForm = >( + key: K, + value: ReturnType[K], + ) => { + setRegisterForm((current) => ({ ...current, [key]: value })); + }; + + const resetToMobileStep = () => { + setStep("mobile"); + setPassword(""); + setOtpCode(""); + setLookupState(null); + setRegisterForm(createEmptyRegisterForm()); + }; + + const ensureValidMobile = (value: string) => { + const normalizedMobile = sanitizeMobile(value); + if (normalizedMobile.length !== 11 || !normalizedMobile.startsWith("09")) { toast({ - title: 'ایمیل لازم است', - description: 'برای ارسال لینک تأیید، ابتدا ایمیل را وارد کنید.', - variant: 'destructive', + title: "شماره موبایل نامعتبر است", + description: "لطفاً شماره موبایل را به شکل 09xxxxxxxxx وارد کنید.", + variant: "destructive", }); + return null; + } + return normalizedMobile; + }; + + const sendOtpAndAdvance = async (mode: "login" | "register", mobileValue = mobile) => { + const normalizedMobile = ensureValidMobile(mobileValue); + if (!normalizedMobile) { return; } - if (!isValidEmailBasic(email)) { - toast({ title: 'ایمیل نامعتبر', description: 'فرمت ایمیل درست نیست.', variant: 'destructive' }); - return; - } + try { - setResendLoading(true); - await api.resendVerification(email); + setAuthLoading(true); + const payload = await api.sendOtp({ mobile: normalizedMobile, mode }); + setMobile(normalizedMobile); + setOtpCode(""); + setOtpCooldowns((current) => ({ + ...current, + [mode]: Math.min(payload.expires_in_seconds, 120), + })); + setStep(mode === "login" ? "otp_login" : "otp_register"); toast({ - title: 'ایمیل ارسال شد', - description: 'اگر در صندوق ورودی نیست، پوشهٔ هرزنامه (اسپم) را بررسی کنید.', - variant: 'success', + title: "کد تایید ارسال شد", + description: payload.message, + variant: "success", }); } catch (error: unknown) { toast({ - title: 'خطا در ارسال', - description: resolveErrorMessage(error, 'مشکلی رخ داد'), - variant: 'destructive', + title: "ارسال کد ناموفق بود", + description: resolveErrorMessage(error, "ارسال کد پیامکی انجام نشد."), + variant: "destructive", }); } finally { - setResendLoading(false); + setAuthLoading(false); } }; - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - try { - const email = sanitizeNoFaNoSpace(loginData.email.trim()); - const password = sanitizeNoFaNoSpace(loginData.password); - if (!email || !isValidEmailBasic(email)) { - throw new Error('ایمیل نامعتبر است.'); - } - if (!password || DISALLOW_PERSIAN_OR_SPACE.test(loginData.password)) { - throw new Error('رمز عبور نباید شامل فاصله یا حروف فارسی باشد.'); - } - await login(email, password); - toast({ title: 'خوش آمدید', description: 'با موفقیت وارد شدید', variant: 'success' }); - navigate('/'); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - const isUnverified = - /please verify your email/i.test(msg) || // EN - /ایمیل.*تایید نشده|لطفاً.*ایمیل.*را.*تأیید/i.test(msg); // FA - - if (isUnverified) { - setUnverified(true); - toast({ - title: 'ایمیل شما تأیید نشده است', - description: 'برای ورود باید ایمیل را تأیید کنید. می‌توانید لینک تأیید را دوباره ارسال کنید.', - variant: 'destructive', - }); - } else { - toast({ title: 'خطا', description: msg || 'خطا در ورود', variant: 'destructive' }); - } - } finally { - setLoading(false); - } - }; - - const validateRegister = () => { - const errs: RegisterErrors = {}; - const isBlank = (s: string) => !s || !s.trim(); - - const email = sanitizeNoFaNoSpace(registerData.email.trim()); - const username = registerData.username.trim(); - const password = registerData.password; - - if (isBlank(email)) errs.email = 'ایمیل را وارد کنید'; - else if (!isValidEmailBasic(email)) errs.email = 'فرمت ایمیل نامعتبر است'; - else if (DISALLOW_PERSIAN_OR_SPACE.test(registerData.email)) errs.email = 'ایمیل نباید شامل فاصله یا حروف فارسی باشد'; - - if (isBlank(username)) errs.username = 'نام کاربری را وارد کنید'; - else if (!USERNAME_REGEX.test(username)) errs.username = 'فقط حروف لاتین، اعداد، نقطه، آندرلاین و خط تیره (حداقل ۳ کاراکتر)'; - - if (isBlank(password)) errs.password = 'رمز عبور را وارد کنید'; - else if (password.length < MIN_PASSWORD_LENGTH) errs.password = `حداقل ${MIN_PASSWORD_LENGTH} کاراکتر`; - else if (DISALLOW_PERSIAN_OR_SPACE.test(password)) errs.password = 'رمز عبور نباید شامل فاصله یا حروف فارسی باشد'; - - if (isBlank(registerData.first_name)) errs.first_name = 'نام را وارد کنید'; - if (isBlank(registerData.last_name)) errs.last_name = 'نام خانوادگی را وارد کنید'; - if (!registerData.university) errs.university = 'دانشگاه را انتخاب کنید'; - - setRegErrors(errs); - return Object.keys(errs).length === 0; - }; - - const handleRegister = async (e: React.FormEvent) => { - e.preventDefault(); - if (!validateRegister()) { - toast({ title: 'اطلاعات ناقص/نامعتبر', description: 'فیلدهای اجباری را درست تکمیل کنید.', variant: 'destructive' }); + const handleMobileStep = async (event: React.FormEvent) => { + event.preventDefault(); + const normalizedMobile = ensureValidMobile(mobile); + if (!normalizedMobile) { return; } - setLoading(true); try { - await api.register({ - email: sanitizeNoFaNoSpace(registerData.email.trim()), - username: registerData.username.trim(), - password: registerData.password, // سرور هم اعتبارسنجی کند - first_name: registerData.first_name.trim(), - last_name: registerData.last_name.trim(), - student_id: registerData.student_id?.trim() || null, - year_of_study: registerData.year_of_study ? parseInt(registerData.year_of_study, 10) : null, - major: registerData.major || null, - university: registerData.university || null, - }); + setAuthLoading(true); + const lookup = await api.checkMobile(normalizedMobile); + setMobile(normalizedMobile); + setLookupState(lookup); + setPassword(""); + setOtpCode(""); + + if (lookup.exists && lookup.has_password) { + setStep("password"); + return; + } + + await sendOtpAndAdvance(lookup.exists ? "login" : "register", normalizedMobile); + } catch (error: unknown) { toast({ - title: 'ثبت‌نام موفق', - description: 'ثبت‌نام با موفقیت انجام شد. لطفاً ایمیل خود را تأیید کنید.', - variant: 'success', - }); - setTab('login'); - setLoginData(() => ({ ...initialLogin, email: registerData.email })); - setRegisterData(initialRegister); - } catch (error) { - toast({ - title: 'خطا', - description: error instanceof Error ? error.message : 'خطا در ثبت‌نام', - variant: 'destructive', + title: "بررسی شماره موبایل ناموفق بود", + description: resolveErrorMessage(error, "امکان ادامه با این شماره موبایل وجود ندارد."), + variant: "destructive", }); } finally { - setLoading(false); + setAuthLoading(false); } }; - const invalidClass = 'border-destructive focus-visible:ring-destructive'; + const handlePasswordLogin = async (event: React.FormEvent) => { + event.preventDefault(); + if (!password) { + toast({ + title: "رمز عبور لازم است", + description: "برای ادامه، رمز عبور حساب را وارد کنید.", + variant: "destructive", + }); + return; + } - // فقط اعداد برای سال ورودی - const onYearChange = (v: string) => v.replace(/\D/g, ''); + try { + setAuthLoading(true); + await login(mobile, password); + toast({ + title: "ورود انجام شد", + description: "خوش آمدید. حساب شما آماده استفاده است.", + variant: "success", + }); + navigate("/profile"); + } catch (error: unknown) { + toast({ + title: "ورود ناموفق بود", + description: resolveErrorMessage(error, "اطلاعات ورود صحیح نیست."), + variant: "destructive", + }); + } finally { + setAuthLoading(false); + } + }; - useEffect(() => { - setTab('login'); - }, []); + const handleOtpLogin = async (event: React.FormEvent) => { + event.preventDefault(); + if (otpCode.length !== OTP_LENGTH) { + toast({ + title: "کد تایید ناقص است", + description: "کد ۵ رقمی پیامک‌شده را کامل وارد کنید.", + variant: "destructive", + }); + return; + } + + try { + setAuthLoading(true); + await loginWithOtp(mobile, otpCode); + toast({ + title: "ورود پیامکی انجام شد", + description: "با موفقیت وارد حساب کاربری خود شدید.", + variant: "success", + }); + navigate("/profile"); + } catch (error: unknown) { + toast({ + title: "ورود با کد ناموفق بود", + description: resolveErrorMessage(error, "کد واردشده معتبر نیست."), + variant: "destructive", + }); + } finally { + setAuthLoading(false); + } + }; + + const handleRegisterOtpVerification = async (event: React.FormEvent) => { + event.preventDefault(); + if (otpCode.length !== OTP_LENGTH) { + toast({ + title: "کد تایید ناقص است", + description: "کد ۵ رقمی پیامک‌شده را کامل وارد کنید.", + variant: "destructive", + }); + return; + } + + try { + setAuthLoading(true); + await api.verifyRegisterOtp({ mobile, code: otpCode }); + setStep("register_details"); + toast({ + title: "موبایل تایید شد", + description: "حالا اطلاعات تکمیلی حساب را وارد کنید.", + variant: "success", + }); + } catch (error: unknown) { + toast({ + title: "تایید کد ناموفق بود", + description: resolveErrorMessage(error, "کد واردشده معتبر نیست."), + variant: "destructive", + }); + } finally { + setAuthLoading(false); + } + }; + + const handleRegister = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!USERNAME_REGEX.test(registerForm.username)) { + toast({ + title: "نام کاربری نامعتبر است", + description: "نام کاربری باید ۳ تا ۳۰ کاراکتر و فقط شامل حروف لاتین، عدد، نقطه، آندرلاین یا خط تیره باشد.", + variant: "destructive", + }); + return; + } + + if (registerForm.password.length < 8) { + toast({ + title: "رمز عبور کوتاه است", + description: "رمز عبور باید حداقل ۸ کاراکتر داشته باشد.", + variant: "destructive", + }); + return; + } + + if (registerForm.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerForm.email.trim())) { + toast({ + title: "ایمیل نامعتبر است", + description: "اگر ایمیل وارد می‌کنید، فرمت آن باید صحیح باشد.", + variant: "destructive", + }); + return; + } + + try { + setAuthLoading(true); + await api.register({ + username: registerForm.username, + mobile, + code: otpCode, + password: registerForm.password, + email: registerForm.email ? sanitizeText(registerForm.email) : null, + first_name: registerForm.first_name ? sanitizeText(registerForm.first_name) : null, + last_name: registerForm.last_name ? sanitizeText(registerForm.last_name) : null, + student_id: registerForm.student_id ? sanitizeMobile(registerForm.student_id) : null, + year_of_study: registerForm.year_of_study ? Number(registerForm.year_of_study) : null, + major: registerForm.major, + university: registerForm.university, + }); + + toast({ + title: "ثبت‌نام کامل شد", + description: "اکنون می‌توانید با موبایل و رمز عبور یا کد پیامکی وارد شوید.", + variant: "success", + }); + setLookupState({ exists: true, has_password: true }); + setPassword(""); + setRegisterForm(createEmptyRegisterForm()); + setOtpCode(""); + setStep("password"); + } catch (error: unknown) { + toast({ + title: "ثبت‌نام ناموفق بود", + description: resolveErrorMessage(error, "اطلاعات ارسالی قابل پذیرش نیست."), + variant: "destructive", + }); + } finally { + setAuthLoading(false); + } + }; + + const renderStepHeader = () => + step !== "mobile" ? ( +
+
+

شماره موبایل انتخاب‌شده

+

+ {mobile} +

+
+ +
+ ) : null; + + const renderMobileStep = () => ( +
+
+ + setMobile(sanitizeMobile(event.target.value))} + placeholder="09xxxxxxxxx" + className="h-12 rounded-2xl" + /> +
+ + + +
+
+ +
+
+ یا +
+
+ + +
+ ); + + const renderPasswordStep = () => ( +
+ {renderStepHeader()} + +
+ + setPassword(event.target.value)} + className="h-12 rounded-2xl" + /> +
+ + + + + + + رمز عبور را فراموش کرده‌ام + +
+ ); + + const renderOtpStep = (mode: "login" | "register") => { + const isLogin = mode === "login"; + + return ( +
+ {renderStepHeader()} + +
+ + setOtpCode(normalizeDigits(value))} disabled={authLoading} /> +
+ +
+ + +
+ + {isLogin && lookupState?.has_password ? ( + + ) : null} +
+ ); + }; + + const renderRegisterDetailsStep = () => ( +
+ {renderStepHeader()} + +
+ این شماره موبایل با موفقیت تایید شده است. برای ثبت‌نام فقط کافی است اطلاعات باقی‌مانده را تکمیل کنید. +
+ +
+
+ + updateRegisterForm("first_name", event.target.value)} + className="h-12 rounded-2xl" + /> +
+
+ + updateRegisterForm("last_name", event.target.value)} + className="h-12 rounded-2xl" + /> +
+
+ +
+
+ + updateRegisterForm("username", sanitizeUsername(event.target.value))} + placeholder="latin.username" + className="h-12 rounded-2xl" + /> +
+
+ + updateRegisterForm("password", event.target.value)} + className="h-12 rounded-2xl" + /> +
+
+ +
+
+ + updateRegisterForm("email", event.target.value)} + placeholder="user@example.com" + className="h-12 rounded-2xl" + /> +
+
+ + updateRegisterForm("student_id", sanitizeMobile(event.target.value))} + className="h-12 rounded-2xl" + /> +
+
+ +
+
+ + {universitiesLoading ? ( +
+ ) : ( + updateRegisterForm("university", value)} + placeholder="انتخاب دانشگاه" + searchPlaceholder="نام دانشگاه را بنویسید..." + emptyText="دانشگاهی پیدا نشد" + dir="rtl" + /> + )} +
+
+ + {majorsLoading ? ( +
+ ) : ( + updateRegisterForm("major", value)} + placeholder="انتخاب رشته" + searchPlaceholder="نام رشته را بنویسید..." + emptyText="رشته‌ای پیدا نشد" + dir="rtl" + /> + )} +
+
+ +
+ + updateRegisterForm("year_of_study", sanitizeMobile(event.target.value))} + className="h-12 rounded-2xl" + /> +
+ + + +

+ ایمیل در این مرحله اختیاری است و برای ارسال پیام استفاده نخواهد شد. اطلاع‌رسانی‌های مهم از طریق پیامک یا اعلان داخل سایت انجام می‌شوند. +

+ + ); return ( - <> - - {pageTitle} - - - - - - - - - - - - - - - -
- - - انجمن علمی کامپیوتر گیلان - ورود یا ثبت‌نام در سیستم - - - setTab(v as 'login' | 'register')} dir="rtl"> - - ورود - ثبت‌نام - - - {/* ورود */} - -
-
- - setLoginData({ ...loginData, email: sanitizeNoFaNoSpace(e.target.value) })} - /> +
+
+ + + {stepMeta.title} + {stepMeta.description} + + +
+
+ +
+

نکته مهم برای کاربران قبلی

+

+ اگر دسترسی به ایمیل ندارید و رمز عبور را فراموش کرده‌اید، در همین مرحله از دکمه گوگل استفاده کنید. اگر ایمیل حساب شما با گوگل یکسان باشد، می‌توانید حساب را بازیابی کرده و بعد موبایل خود را تایید کنید. +

-
- - setLoginData({ ...loginData, password: sanitizeNoFaNoSpace(e.target.value) })} - /> -
- +
+
- - فراموشی رمز عبور؟ - - - - - {unverified && ( -
-

- حساب شما هنوز تأیید نشده است. لطفاً پوشه‌ی اسپم ایمیل خود را بررسی کنید یا لینک تأیید را دوباره دریافت کنید. -

-
- )} - - - - {/* ثبت‌نام */} - -
-
- - { - const val = sanitizeNoFaNoSpace(e.target.value); - setRegisterData({ ...registerData, email: val }); - if (regErrors.email) setRegErrors((p) => ({ ...p, email: undefined })); - }} - className={regErrors.email ? invalidClass : undefined} - aria-invalid={!!regErrors.email} - /> - {regErrors.email &&

{regErrors.email}

} -
- -
- - { - const val = sanitizeUsername(e.target.value); - setRegisterData({ ...registerData, username: val }); - if (regErrors.username) setRegErrors((p) => ({ ...p, username: undefined })); - }} - pattern="[A-Za-z0-9._-]{3,30}" - className={regErrors.username ? invalidClass : undefined} - aria-invalid={!!regErrors.username} - /> - {regErrors.username &&

{regErrors.username}

} -
- -
-
- - { - setRegisterData({ ...registerData, first_name: e.target.value }); - if (regErrors.first_name) setRegErrors((p) => ({ ...p, first_name: undefined })); - }} - className={regErrors.first_name ? invalidClass : undefined} - aria-invalid={!!regErrors.first_name} - /> - {regErrors.first_name &&

{regErrors.first_name}

} -
-
- - { - setRegisterData({ ...registerData, last_name: e.target.value }); - if (regErrors.last_name) setRegErrors((p) => ({ ...p, last_name: undefined })); - }} - className={regErrors.last_name ? invalidClass : undefined} - aria-invalid={!!regErrors.last_name} - /> - {regErrors.last_name &&

{regErrors.last_name}

} -
-
- -
- - {universitiesLoading ? ( -
- ) : ( - <> - { - setRegisterData({ ...registerData, university: v }); - if (regErrors.university) setRegErrors((p) => ({ ...p, university: undefined })); - }} - placeholder="انتخاب دانشگاه" - searchPlaceholder="نام دانشگاه را بنویسید…" - emptyText="دانشگاهی پیدا نشد" - className={regErrors.university ? "border-destructive focus-visible:ring-destructive" : undefined} - dir="rtl" - /> - {regErrors.university && ( -

{regErrors.university}

- )} - - )} -
- -
- - - setRegisterData({ ...registerData, student_id: onlyAsciiDigits(e.target.value) }) - } - onKeyDown={(e) => { - const allowed = ['Backspace','Delete','ArrowLeft','ArrowRight','Tab','Home','End']; - if (/^[0-9]$/.test(e.key)) return; // فقط 0-9 - if (allowed.includes(e.key)) return; // کلیدهای کنترلی - if ((e.ctrlKey || e.metaKey) && ['a','c','v','x'].includes(e.key.toLowerCase())) return; // میانبرها - e.preventDefault(); // بقیه ممنوع - }} - /> -
- -
-
- - setRegisterData({ ...registerData, year_of_study: onYearChange(e.target.value) })} - /> -
- -
- - {majorsLoading ? ( -
- ) : ( - setRegisterData({ ...registerData, major: v })} - placeholder="انتخاب رشته" - searchPlaceholder="نام رشته را بنویسید…" - emptyText="رشته‌ای پیدا نشد" - dir="rtl" - /> - )} -
-
- -
- - { - const val = sanitizeNoFaNoSpace(e.target.value); - setRegisterData({ ...registerData, password: val }); - if (regErrors.password) setRegErrors((p) => ({ ...p, password: undefined })); - }} - className={regErrors.password ? invalidClass : undefined} - aria-invalid={!!regErrors.password} - /> - {regErrors.password ? ( -

{regErrors.password}

- ) : ( -

- حداقل {MIN_PASSWORD_LENGTH} کاراکتر — بدون فاصله و حروف فارسی -

- )} -
- - - - - - - + {step === "mobile" ? renderMobileStep() : null} + {step === "password" ? renderPasswordStep() : null} + {step === "otp_login" ? renderOtpStep("login") : null} + {step === "otp_register" ? renderOtpStep("register") : null} + {step === "register_details" ? renderRegisterDetailsStep() : null} + +
- +
); } diff --git a/src/views/GoogleAuthCallback.tsx b/src/views/GoogleAuthCallback.tsx new file mode 100644 index 0000000..30d238b --- /dev/null +++ b/src/views/GoogleAuthCallback.tsx @@ -0,0 +1,471 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, ArrowLeft, CheckCircle2, Loader2 } from "lucide-react"; +import SearchableCombobox from "@/components/SearchableCombobox"; +import OtpCodeField from "@/components/OtpCodeField"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useAuth } from "@/contexts/AuthContext"; +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/lib/api"; +import { Link, useNavigate, useSearchParams } from "@/lib/router"; +import type { GoogleFlowResponseSchema } from "@/lib/types"; +import { resolveErrorMessage } from "@/lib/utils"; + +type CallbackStep = "loading" | "collect_profile" | "claim_required" | "error"; + +const normalizeDigits = (value: string) => + value + .replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0)) + .replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660)); +const sanitizeMobile = (value: string) => normalizeDigits(value).replace(/[^\d]/g, ""); +const sanitizeUsername = (value: string) => value.replace(/[^A-Za-z0-9._-]/g, ""); + +export default function GoogleAuthCallback() { + const navigate = useNavigate(); + const { toast } = useToast(); + const { setSessionTokens } = useAuth(); + const [searchParams] = useSearchParams(); + const flow = searchParams.get("flow") || ""; + const [step, setStep] = useState("loading"); + const [loading, setLoading] = useState(false); + const [otpCooldown, setOtpCooldown] = useState(0); + const [googleFlow, setGoogleFlow] = useState(null); + const [profileForm, setProfileForm] = useState({ + mobile: "", + username: "", + first_name: "", + last_name: "", + student_id: "", + year_of_study: "", + major: null as string | null, + university: null as string | null, + }); + const [claimCode, setClaimCode] = useState(""); + + const { data: majors = [] } = useQuery({ + queryKey: ["majors"], + queryFn: () => api.getMajors(), + staleTime: 7 * 24 * 60 * 60 * 1000, + enabled: step === "collect_profile", + }); + const { data: universities = [] } = useQuery({ + queryKey: ["universities"], + queryFn: () => api.getUniversities(), + staleTime: 7 * 24 * 60 * 60 * 1000, + enabled: step === "collect_profile", + }); + + const majorItems = useMemo( + () => majors.map((major) => ({ value: String(major.code), label: major.label })), + [majors], + ); + const universityItems = useMemo( + () => universities.map((university) => ({ value: String(university.code), label: university.label })), + [universities], + ); + + useEffect(() => { + if (otpCooldown <= 0) { + return; + } + const timer = window.setTimeout(() => setOtpCooldown((current) => current - 1), 1000); + return () => window.clearTimeout(timer); + }, [otpCooldown]); + + const handleAuthenticatedFlow = useCallback(async (payload: GoogleFlowResponseSchema) => { + if (!payload.access_token || !payload.refresh_token) { + throw new Error("توکن‌های ورود گوگل دریافت نشد."); + } + await setSessionTokens(payload.access_token, payload.refresh_token); + toast({ + title: "ورود با گوگل کامل شد", + description: "حساب شما با موفقیت بازیابی یا متصل شد.", + variant: "success", + }); + navigate("/profile", { replace: true }); + }, [navigate, setSessionTokens, toast]); + + const applyFlow = useCallback(async (payload: GoogleFlowResponseSchema) => { + setGoogleFlow(payload); + if (payload.status === "authenticated") { + await handleAuthenticatedFlow(payload); + return; + } + + if (payload.status === "claim_required") { + setProfileForm((current) => ({ + ...current, + mobile: payload.mobile || current.mobile, + })); + setStep("claim_required"); + return; + } + + if (payload.status === "collect_profile") { + setProfileForm((current) => ({ + ...current, + mobile: payload.mobile || current.mobile, + first_name: payload.first_name || current.first_name, + last_name: payload.last_name || current.last_name, + })); + setStep("collect_profile"); + return; + } + + setStep("error"); + }, [handleAuthenticatedFlow]); + + useEffect(() => { + if (!flow) { + setStep("error"); + return; + } + + let cancelled = false; + const loadFlow = async () => { + setLoading(true); + try { + const payload = await api.getGoogleFlow(flow); + if (!cancelled) { + await applyFlow(payload); + } + } catch (error: unknown) { + if (!cancelled) { + toast({ + title: "اتصال گوگل کامل نشد", + description: resolveErrorMessage(error, "لینک گوگل نامعتبر یا منقضی شده است."), + variant: "destructive", + }); + setStep("error"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + void loadFlow(); + return () => { + cancelled = true; + }; + }, [applyFlow, flow, toast]); + + const handleCompleteProfile = async (event: React.FormEvent) => { + event.preventDefault(); + try { + setLoading(true); + const payload = await api.completeGoogleSignup({ + flow, + mobile: sanitizeMobile(profileForm.mobile), + username: googleFlow?.resolution === "new_account" ? profileForm.username : undefined, + student_id: profileForm.student_id || undefined, + year_of_study: profileForm.year_of_study ? Number(profileForm.year_of_study) : undefined, + major: profileForm.major || undefined, + university: profileForm.university || undefined, + first_name: profileForm.first_name || undefined, + last_name: profileForm.last_name || undefined, + }); + if (payload.status === "claim_required") { + setOtpCooldown(120); + } + await applyFlow(payload); + } catch (error: unknown) { + toast({ + title: "تکمیل ورود با گوگل ناموفق بود", + description: resolveErrorMessage(error, "اطلاعات تکمیلی قابل پذیرش نیست."), + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const handleResendClaimOtp = async () => { + try { + setLoading(true); + await api.resendGoogleClaimOtp(flow); + setOtpCooldown(120); + toast({ + title: "کد پیامکی دوباره ارسال شد", + description: "کد تازه را وارد کنید تا فرایند اتصال حساب کامل شود.", + variant: "success", + }); + } catch (error: unknown) { + toast({ + title: "ارسال مجدد ناموفق بود", + description: resolveErrorMessage(error, "امکان ارسال دوباره کد وجود ندارد."), + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const handleVerifyClaim = async (event: React.FormEvent) => { + event.preventDefault(); + try { + setLoading(true); + const payload = await api.verifyGoogleClaim(flow, normalizeDigits(claimCode)); + await applyFlow(payload); + } catch (error: unknown) { + toast({ + title: "تأیید حساب ناموفق بود", + description: resolveErrorMessage(error, "کد پیامکی معتبر نیست."), + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const title = + step === "claim_required" + ? "تأیید شماره موبایل" + : step === "collect_profile" + ? "تکمیل اطلاعات حساب" + : step === "error" + ? "ورود با گوگل متوقف شد" + : "در حال بازیابی حساب"; + + const description = + step === "claim_required" + ? "برای اتصال نهایی حساب، مالکیت شماره موبایل را با کد پیامکی تأیید کنید." + : step === "collect_profile" + ? googleFlow?.resolution === "existing_email_claim" + ? "ایمیل گوگل شما با یک حساب قدیمی تطابق دارد. موبایل همان حساب را تأیید کنید تا بازیابی کامل شود." + : "این اولین ورود شما با گوگل است. اطلاعات تکمیلی را ثبت کنید تا حساب شما ساخته شود." + : step === "error" + ? "لینک این فرایند منقضی شده یا از سمت گوگل کامل نشده است." + : "چند لحظه صبر کنید تا وضعیت حساب گوگل شما بررسی شود."; + + return ( +
+
+ + + {title} + {description} + + + {step === "loading" ? ( +
+ + در حال دریافت وضعیت حساب گوگل... +
+ ) : null} + + {step === "collect_profile" ? ( +
+ {googleFlow?.email ? ( +
+

ایمیل متصل به گوگل

+

{googleFlow.email}

+ {googleFlow.mobile_hint ? ( +

+ راهنما: حساب قدیمی با موبایل {googleFlow.mobile_hint} شناخته شده است. +

+ ) : null} +
+ ) : null} + +
+
+ + setProfileForm((current) => ({ ...current, first_name: event.target.value }))} + className="h-12 rounded-2xl" + /> +
+
+ + setProfileForm((current) => ({ ...current, last_name: event.target.value }))} + className="h-12 rounded-2xl" + /> +
+
+ +
+
+ + setProfileForm((current) => ({ ...current, mobile: sanitizeMobile(event.target.value) }))} + className="h-12 rounded-2xl" + placeholder="09xxxxxxxxx" + /> +
+ {googleFlow?.resolution === "new_account" ? ( +
+ + setProfileForm((current) => ({ ...current, username: sanitizeUsername(event.target.value) }))} + className="h-12 rounded-2xl" + placeholder="latin.username" + /> +
+ ) : null} +
+ + {googleFlow?.resolution === "new_account" ? ( + <> +
+
+ + setProfileForm((current) => ({ ...current, university: value }))} + placeholder="انتخاب دانشگاه" + searchPlaceholder="نام دانشگاه را بنویسید..." + emptyText="دانشگاهی پیدا نشد" + dir="rtl" + /> +
+
+ + setProfileForm((current) => ({ ...current, major: value }))} + placeholder="انتخاب رشته" + searchPlaceholder="نام رشته را بنویسید..." + emptyText="رشته‌ای پیدا نشد" + dir="rtl" + /> +
+
+ +
+
+ + setProfileForm((current) => ({ ...current, student_id: sanitizeMobile(event.target.value) }))} + className="h-12 rounded-2xl" + /> +
+
+ + setProfileForm((current) => ({ ...current, year_of_study: sanitizeMobile(event.target.value) }))} + className="h-12 rounded-2xl" + /> +
+
+ + ) : ( +
+ این مسیر برای بازیابی حساب قدیمی است. پس از ثبت موبایل، یک کد پیامکی ارسال می‌شود تا مالکیت حساب تأیید شود. +
+ )} + + +
+ ) : null} + + {step === "claim_required" ? ( +
+
+

کد به این شماره ارسال شده است

+

{googleFlow?.mobile_hint || googleFlow?.mobile || profileForm.mobile}

+ {googleFlow?.resolution === "new_account" ? ( +

+ بعد از تأیید این کد، حساب جدید شما ساخته می‌شود و مستقیماً وارد سایت می‌شوید. +

+ ) : ( +

+ بعد از تأیید این کد، حساب قدیمی شما به گوگل متصل می‌شود و بازیابی کامل خواهد شد. +

+ )} +
+ +
+ + setClaimCode(normalizeDigits(value))} disabled={loading} /> +
+ +
+ + +
+
+ ) : null} + + {step === "error" ? ( +
+
+
+ +
+

فرایند گوگل کامل نشد

+

+ دوباره از صفحه ورود شروع کنید. اگر مشکل ادامه داشت، از ورود با موبایل یا بازیابی با موبایل استفاده کنید. +

+
+
+
+
+ + +
+
+ ) : null} +
+
+
+
+ ); +} diff --git a/src/views/Profile.tsx b/src/views/Profile.tsx index 5596bea..9d78f75 100644 --- a/src/views/Profile.tsx +++ b/src/views/Profile.tsx @@ -643,8 +643,8 @@ export default function Profile() {
- - {me?.is_email_verified ? "ایمیل تأیید شده" : "در انتظار تأیید ایمیل"} + + {me?.is_mobile_verified ? "موبایل تأیید شده" : "نیازمند تأیید موبایل"} {isAdminUser ? دسترسی مدیریتی : null} @@ -786,6 +786,7 @@ export default function Profile() { وضعیت حساب + - {me?.is_email_verified ? ( + {me?.is_mobile_verified ? ( ) : ( )} - {me?.is_email_verified ? "تأیید شده" : "در انتظار"} + {me?.is_mobile_verified ? "تأیید شده" : "در انتظار"} } /> + - درخواست تغییر رمز عبور + بازیابی یا تغییر رمز با موبایل diff --git a/src/views/ResetPasswordConfirm.tsx b/src/views/ResetPasswordConfirm.tsx index 8352d4e..4aba03c 100644 --- a/src/views/ResetPasswordConfirm.tsx +++ b/src/views/ResetPasswordConfirm.tsx @@ -1,77 +1,77 @@ "use client"; -import { useState } from 'react'; -import { useParams, useNavigate } from '@/lib/router'; -import { useToast } from '@/hooks/use-toast'; -import { api } from '@/lib/api'; -import { resolveErrorMessage } from '@/lib/utils'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, ArrowLeft, Loader2, ShieldAlert } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { Link, useParams } from "@/lib/router"; +import { resolveErrorMessage } from "@/lib/utils"; export default function ResetPasswordConfirm() { const { token } = useParams<{ token: string }>(); - const navigate = useNavigate(); - const { toast } = useToast(); - const [password, setPassword] = useState(''); - const [confirm, setConfirm] = useState(''); - const [loading, setLoading] = useState(false); + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["legacy-reset-guidance", token], + queryFn: () => api.getLegacyResetTokenMessage(token || ""), + retry: false, + }); - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!token) { - toast({ title: 'توکن نامعتبر است', variant: 'destructive' }); - return; - } - if (password.length < 8) { - toast({ title: 'رمز عبور کوتاه است', description: 'حداقل ۸ کاراکتر', variant: 'destructive' }); - return; - } - if (password !== confirm) { - toast({ title: 'عدم تطابق', description: 'تکرار رمز با رمز جدید یکسان نیست', variant: 'destructive' }); - return; - } - try { - setLoading(true); - await api.resetPasswordConfirm(token, password); - toast({ title: 'رمز عبور با موفقیت تغییر کرد', variant: 'success' }); - navigate('/auth'); - } catch (error: unknown) { - toast({ - title: 'خطا', - description: resolveErrorMessage(error, 'مشکلی رخ داد'), - variant: 'destructive', - }); - } finally { - setLoading(false); - } - }; + const message = isError + ? resolveErrorMessage(error, "این مسیر دیگر برای بازیابی رمز عبور فعال نیست.") + : data?.message || + "لینک بازیابی ایمیلی غیرفعال شده است. برای ادامه از بازیابی با موبایل یا ورود با گوگل استفاده کنید."; return ( -
- - - تعیین رمز جدید - رمز عبور جدید را وارد کنید - - -
-
- - setPassword(e.target.value)} /> +
+
+ + +
+
-
- - setConfirm(e.target.value)} /> + لینک بازیابی قدیمی غیرفعال شده است + + مسیرهای مبتنی بر ایمیل دیگر برای بازیابی حساب استفاده نمی‌شوند. + + + +
+ {isLoading ? ( + + + در حال دریافت راهنمای بازیابی... + + ) : ( + message + )}
- - -
- + +
+
+ +
+

راه جایگزین

+

+ از صفحه بازیابی با موبایل استفاده کنید. اگر موبایل ثبت‌شده را هم در دسترس ندارید، ورود با گوگل و همان ایمیل قبلی بهترین مسیر بازیابی است. +

+
+
+
+ +
+ + +
+ + +
); } - diff --git a/src/views/ResetPasswordRequest.tsx b/src/views/ResetPasswordRequest.tsx index 0975da8..971aee8 100644 --- a/src/views/ResetPasswordRequest.tsx +++ b/src/views/ResetPasswordRequest.tsx @@ -1,35 +1,103 @@ "use client"; -import { useState } from 'react'; -import { useToast } from '@/hooks/use-toast'; -import { api } from '@/lib/api'; -import { resolveErrorMessage } from '@/lib/utils'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; +import { useEffect, useState } from "react"; +import { AlertTriangle, Loader2, ShieldCheck } from "lucide-react"; +import OtpCodeField from "@/components/OtpCodeField"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/lib/api"; +import { Link } from "@/lib/router"; +import { resolveErrorMessage } from "@/lib/utils"; + +const normalizeDigits = (value: string) => + value + .replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0)) + .replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660)); +const sanitizeMobile = (value: string) => normalizeDigits(value).replace(/[^\d]/g, ""); export default function ResetPasswordRequest() { const { toast } = useToast(); - const [email, setEmail] = useState(''); + const [mobile, setMobile] = useState(""); + const [code, setCode] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); + const [cooldown, setCooldown] = useState(0); - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + useEffect(() => { + if (cooldown <= 0) { + return; + } + const timer = window.setTimeout(() => setCooldown((current) => current - 1), 1000); + return () => window.clearTimeout(timer); + }, [cooldown]); + + const handleSendOtp = async () => { try { setLoading(true); - await api.requestPasswordReset(email); + const response = await api.sendOtp({ + mobile: sanitizeMobile(mobile), + mode: "reset_password", + }); + setCooldown(Math.min(response.expires_in_seconds, 120)); toast({ - title: 'اگر ایمیلی ثبت شده باشد، لینک بازیابی ارسال شد', - description: 'ایمیل خود را بررسی کنید.', - variant: 'success' + title: "کد بازیابی ارسال شد", + description: response.message, + variant: "success", }); } catch (error: unknown) { - // بک‌اند 200 می‌دهد حتی اگر ایمیل نباشد؛ اما اگر اروری بیاید، نشان بده toast({ - title: 'خطا', - description: resolveErrorMessage(error, 'مشکلی رخ داد'), - variant: 'destructive', + title: "ارسال کد انجام نشد", + description: resolveErrorMessage(error, "امکان ارسال پیامک بازیابی وجود ندارد."), + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const handleResetPassword = async (event: React.FormEvent) => { + event.preventDefault(); + if (newPassword.length < 8) { + toast({ + title: "رمز عبور کوتاه است", + description: "رمز جدید باید حداقل ۸ کاراکتر داشته باشد.", + variant: "destructive", + }); + return; + } + if (newPassword !== confirmPassword) { + toast({ + title: "عدم تطابق رمزها", + description: "تکرار رمز عبور با رمز جدید یکسان نیست.", + variant: "destructive", + }); + return; + } + + try { + setLoading(true); + await api.resetPassword({ + mobile: sanitizeMobile(mobile), + code: normalizeDigits(code), + new_password: newPassword, + }); + toast({ + title: "رمز عبور تغییر کرد", + description: "اکنون می‌توانید با رمز جدید وارد شوید.", + variant: "success", + }); + setCode(""); + setNewPassword(""); + setConfirmPassword(""); + } catch (error: unknown) { + toast({ + title: "بازیابی ناموفق بود", + description: resolveErrorMessage(error, "کد تأیید یا رمز جدید قابل پذیرش نیست."), + variant: "destructive", }); } finally { setLoading(false); @@ -37,24 +105,131 @@ export default function ResetPasswordRequest() { }; return ( -
- - - بازیابی رمز عبور - ایمیل‌تان را وارد کنید تا لینک بازیابی برای شما ارسال شود - - -
-
- - setEmail(e.target.value)} /> +
+
+ + + بازیابی حساب بدون ایمیل + + بازیابی رمز عبور اکنون با موبایل و کد پیامکی انجام می‌شود. اگر به موبایل ثبت‌شده هم دسترسی ندارید، از همان حساب گوگلی که قبلاً با ایمیل‌تان استفاده می‌کردید کمک بگیرید. + + + +
+
+ +
+

اگر رمز را فراموش کرده‌اید

+

+ می‌توانید از مسیر ورود با گوگل ادامه دهید؛ به شرطی که حساب گوگل شما با ایمیل قدیمی‌تان یکسان باشد. +

+
+
- - -
-
+
+

گام‌های بازیابی

+
    +
  1. ۱. موبایل ثبت‌شده را وارد کنید.
  2. +
  3. ۲. کد پیامکی را دریافت و ثبت کنید.
  4. +
  5. ۳. رمز عبور جدید را تعیین کنید.
  6. +
+
+ + + + + + تغییر رمز عبور با کد پیامکی + + این فرم جایگزین کامل بازیابی ایمیلی است. + + + +
+
+ + setMobile(sanitizeMobile(event.target.value))} + placeholder="09xxxxxxxxx" + className="h-12 rounded-2xl" + /> +
+ +
+ + setCode(normalizeDigits(value))} disabled={loading} /> +
+ +
+
+ + setNewPassword(event.target.value)} + className="h-12 rounded-2xl" + /> +
+
+ + setConfirmPassword(event.target.value)} + className="h-12 rounded-2xl" + /> +
+
+ +
+ + +
+ +
+ + بازگشت به صفحه ورود + +
+
+
+
+
); } diff --git a/src/views/VerifyEmail.tsx b/src/views/VerifyEmail.tsx index 3462105..c4bd337 100644 --- a/src/views/VerifyEmail.tsx +++ b/src/views/VerifyEmail.tsx @@ -1,122 +1,77 @@ "use client"; -import { useEffect } from "react"; -import { useParams, Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { api } from "@/lib/api"; -import { - Card, - CardHeader, - CardTitle, - CardContent, - CardFooter, -} from "@/components/ui/card"; -import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { AlertTriangle, ArrowLeft, Loader2, MailWarning } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Loader2, CheckCircle2, Info, XCircle } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { api } from "@/lib/api"; +import { Link, useParams } from "@/lib/router"; import { resolveErrorMessage } from "@/lib/utils"; -type State = - | { kind: "loading" } - | { kind: "success"; message: string } - | { kind: "already"; message: string } - | { kind: "error"; message: string }; - export default function VerifyEmail() { const { token } = useParams<{ token: string }>(); - - const query = useQuery({ - queryKey: ["verify-email", token], - queryFn: async (): Promise => { - if (!token) throw new Error("توکن تأیید یافت نشد."); - try { - const res = await api.verifyEmail(token); - return { kind: "success", message: "ایمیل شما با موفقیت تأیید شد." }; - } catch (error: unknown) { - const msg: string = resolveErrorMessage(error, "").toLowerCase(); - if (msg.includes("already verified")) { - return { kind: "already", message: "ایمیل شما قبلاً تأیید شده است." }; - } - if (msg.includes("invalid verification token")) { - return { kind: "error", message: "توکن تأیید نامعتبر است." }; - } - return { - kind: "error", - message: "متأسفانه خطایی رخ داد. لطفاً دوباره تلاش کنید.", - }; - } - }, + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["legacy-email-guidance", token], + queryFn: () => api.getLegacyVerifyEmailMessage(token || ""), retry: false, }); - useEffect(() => { - document.title = "تأیید ایمیل"; - }, []); - - const renderBody = () => { - if (query.isLoading || query.data?.kind === "loading") { - return ( -
- - در حال تأیید ایمیل... -
- ); - } - - if (query.isError || query.data?.kind === "error") { - const message = - (query.data && "message" in query.data && query.data.message) || - "خطای ناشناخته رخ داد"; - return ( - - - خطا - {message} - - ); - } - - if (query.data?.kind === "already") { - return ( - - - توجه - {query.data.message} - - ); - } - - // success - return ( - - - تبریک! - ایمیل شما با موفقیت تأیید شد. - - ); - }; + const message = isError + ? resolveErrorMessage(error, "تأیید ایمیل دیگر برای ورود و بازیابی حساب استفاده نمی‌شود.") + : data?.message || + "تأیید ایمیل غیرفعال شده است. برای ادامه باید موبایل خود را تأیید کنید یا با همان حساب گوگل مرتبط وارد شوید."; return ( -
- - - تأیید ایمیل - - {renderBody()} - - -
- - -
-
-
+
+
+ + +
+ +
+ تأیید ایمیل کنار گذاشته شده است + + دسترسی کاربران به ایمیل محدود شده و مسیر تأیید حساب به موبایل و گوگل منتقل شده است. + +
+ +
+ {isLoading ? ( + + + در حال دریافت پیام راهنما... + + ) : ( + message + )} +
+ +
+
+ +
+

برای کاربران قدیمی

+

+ اگر حساب شما قبلاً با ایمیل ساخته شده است، از صفحه ورود گزینه گوگل را بزنید تا در صورت تطابق ایمیل، حساب‌تان به موبایل متصل شود. +

+
+
+
+ +
+ + +
+
+
+
); }