diff --git a/src/bot/__init__.py b/src/bot/__init__.py new file mode 100644 index 0000000..1b083fa --- /dev/null +++ b/src/bot/__init__.py @@ -0,0 +1,10 @@ +""" +Telegram Bot module for Findar fraud detection system. + +This module provides Telegram bot functionality for user interaction, +registration verification, and notification delivery. +""" + +from .app import start_bot + +__all__ = ["start_bot"] diff --git a/src/bot/handlers.py b/src/bot/handlers.py new file mode 100644 index 0000000..d2236c4 --- /dev/null +++ b/src/bot/handlers.py @@ -0,0 +1,96 @@ +""" +Telegram bot message handlers. + +Contains handlers for bot commands and messages. +""" + +from aiogram import Router +from aiogram.filters import CommandStart +from aiogram.types import Message + +from src.core.logging import get_logger +from src.modules.users.repository import UserRepository +from src.storage.sql import get_async_session + +logger = get_logger("bot.handlers") + +# Create router for handlers +router = Router() + + +@router.message(CommandStart()) +async def cmd_start(message: Message) -> None: + """ + Handle /start command. + + Checks if user is registered and provides appropriate response. + """ + telegram_id = message.from_user.id + username = message.from_user.username + + logger.info( + "Received /start command", + component="telegram_bot", + telegram_id=telegram_id, + username=username, + ) + + try: + # Get database session + async for session in get_async_session(): + user_repo = UserRepository(session) + + # Check if user exists by telegram_id + user = await user_repo.get_user_by_telegram_id(telegram_id) + + if user: + # User is registered + await message.answer( + f"✅ Привет, {message.from_user.first_name}!\n\n" + f"Ты уже зарегистрирован в системе Findar.\n" + f"Email: {user.email}\n\n" + f"Ты будешь получать уведомления о подозрительных транзакциях на этот Telegram аккаунт." + ) + logger.info( + "User already registered", + component="telegram_bot", + telegram_id=telegram_id, + user_id=str(user.id), + ) + else: + # User NOT registered + await message.answer( + f"👋 Привет, {message.from_user.first_name}!\n\n" + f"Ты ещё не зарегистрирован в системе Findar.\n\n" + f"Пожалуйста, пройди регистрацию на сайте:\n" + f"https://findar.example.com/register\n\n" + f"Затем возвращайся и снова напиши /start" + ) + logger.info( + "User not registered, sent registration instructions", + component="telegram_bot", + telegram_id=telegram_id, + ) + + break # Exit async generator + + except Exception as e: + logger.exception( + "Error handling /start command", + component="telegram_bot", + telegram_id=telegram_id, + ) + await message.answer( + "❌ Произошла ошибка при обработке команды.\n" + f"Попробуй позже или обратись в поддержку. {e}" + ) + + +@router.message() +async def handle_unknown_message(message: Message) -> None: + """Handle all other messages.""" + await message.answer( + "ℹ️ Я бот для уведомлений о фродовых транзакциях.\n\n" + "Доступные команды:\n" + "/start - Проверить статус регистрации" + ) diff --git a/src/modules/rule_engine/schemas.py b/src/modules/rule_engine/schemas.py index 9b645fb..f549fa4 100644 --- a/src/modules/rule_engine/schemas.py +++ b/src/modules/rule_engine/schemas.py @@ -187,7 +187,7 @@ class MLRuleParams(BaseModel): Parameters for machine learning-based fraud detection rules. ML rules use trained models to assess transaction risk with confidence scores. - The model is accessed via an endpoint URL. + The model is accessed via an endpoint URL or uploaded as a file. """ # Model configuration @@ -206,6 +206,12 @@ class MLRuleParams(BaseModel): # Endpoint configuration endpoint_url: str = Field(description="URL of the ML model inference endpoint") + # Model file path (optional, alternative to endpoint) + model_file_path: str | None = Field( + default=None, + description="Path to uploaded ML model file (alternative to endpoint_url)", + ) + @field_validator("endpoint_url") @classmethod def validate_endpoint_url(cls, v): diff --git a/src/modules/users/notifications_routes.py b/src/modules/users/notifications_routes.py new file mode 100644 index 0000000..d6475de --- /dev/null +++ b/src/modules/users/notifications_routes.py @@ -0,0 +1,234 @@ +""" +User notification settings routes. + +Provides API endpoints for users to manage their notification templates +and channel settings (email/telegram). +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.logging import get_logger +from src.modules.notifications.repository import NotificationRepository +from src.modules.notifications.schemas import ( + NotificationChannelsResponse, + NotificationChannelsUpdate, + TemplateFieldsUpdate, + UserNotificationTemplateResponse, + UserNotificationTemplatesResponse, +) +from src.modules.users.dependencies import get_current_user +from src.modules.users.repository import UserRepository +from src.storage.dependencies import get_db_session +from src.storage.models import User + +logger = get_logger("users.notifications_routes") + +router = APIRouter(prefix="/users/notifications", tags=["user-notifications"]) + + +@router.get( + "/templates/", + response_model=UserNotificationTemplatesResponse, + summary="Get user's notification templates", +) +async def get_user_templates( + current_user: User = Depends(get_current_user), + db_session: AsyncSession = Depends(get_db_session), +) -> UserNotificationTemplatesResponse: + """ + Get current user's email and telegram notification templates. + + Returns both templates with their field visibility settings. + """ + notification_repo = NotificationRepository(db_session) + + # Get email template + email_template_id = getattr(current_user, "email_template_id", None) + if not email_template_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Email template not found. Please contact support.", + ) + + email_template = await notification_repo.get_template(email_template_id) + if not email_template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Email template not found in database.", + ) + + # Get telegram template + telegram_template_id = getattr(current_user, "telegram_template_id", None) + if not telegram_template_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Telegram template not found. Please contact support.", + ) + + telegram_template = await notification_repo.get_template(telegram_template_id) + if not telegram_template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Telegram template not found in database.", + ) + + return UserNotificationTemplatesResponse( + email_template=UserNotificationTemplateResponse.model_validate(email_template), + telegram_template=UserNotificationTemplateResponse.model_validate( + telegram_template + ), + ) + + +@router.patch( + "/templates/email", + response_model=UserNotificationTemplateResponse, + summary="Update email template fields", +) +async def update_email_template( + fields: TemplateFieldsUpdate, + current_user: User = Depends(get_current_user), + db_session: AsyncSession = Depends(get_db_session), +) -> UserNotificationTemplateResponse: + """ + Update email template field visibility settings. + + Only updates the show_* fields that control which information + is included in email notifications. + """ + email_template_id = getattr(current_user, "email_template_id", None) + if not email_template_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Email template not found.", + ) + + notification_repo = NotificationRepository(db_session) + + # Update template fields + fields_dict = fields.model_dump(exclude_unset=True) + updated_template = await notification_repo.update_template_fields( + email_template_id, fields_dict + ) + + if not updated_template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found or update failed.", + ) + + logger.info( + f"User {current_user.id} updated email template fields", + user_id=str(current_user.id), + updated_fields=list(fields_dict.keys()), + ) + + return UserNotificationTemplateResponse.model_validate(updated_template) + + +@router.patch( + "/templates/telegram", + response_model=UserNotificationTemplateResponse, + summary="Update telegram template fields", +) +async def update_telegram_template( + fields: TemplateFieldsUpdate, + current_user: User = Depends(get_current_user), + db_session: AsyncSession = Depends(get_db_session), +) -> UserNotificationTemplateResponse: + """ + Update telegram template field visibility settings. + + Only updates the show_* fields that control which information + is included in telegram notifications. + """ + telegram_template_id = getattr(current_user, "telegram_template_id", None) + if not telegram_template_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Telegram template not found.", + ) + + notification_repo = NotificationRepository(db_session) + + # Update template fields + fields_dict = fields.model_dump(exclude_unset=True) + updated_template = await notification_repo.update_template_fields( + telegram_template_id, fields_dict + ) + + if not updated_template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found or update failed.", + ) + + logger.info( + f"User {current_user.id} updated telegram template fields", + user_id=str(current_user.id), + updated_fields=list(fields_dict.keys()), + ) + + return UserNotificationTemplateResponse.model_validate(updated_template) + + +@router.get( + "/channels/", + response_model=NotificationChannelsResponse, + summary="Get notification channel settings", +) +async def get_notification_channels( + current_user: User = Depends(get_current_user), +) -> NotificationChannelsResponse: + """ + Get current user's notification channel settings. + + Returns whether email and telegram notifications are enabled. + """ + return NotificationChannelsResponse( + email_enabled=getattr(current_user, "email_notifications_enabled", True), + telegram_enabled=getattr(current_user, "telegram_notifications_enabled", True), + ) + + +@router.patch( + "/channels/", + response_model=NotificationChannelsResponse, + summary="Update notification channel settings", +) +async def update_notification_channels( + settings: NotificationChannelsUpdate, + current_user: User = Depends(get_current_user), + db_session: AsyncSession = Depends(get_db_session), +) -> NotificationChannelsResponse: + """ + Enable or disable email/telegram notification channels. + + Allows users to turn on/off notifications for each channel independently. + """ + user_repo = UserRepository(db_session) + + updated_user = await user_repo.update_notification_channels( + user_id=current_user.id, + email_enabled=settings.email_enabled, + telegram_enabled=settings.telegram_enabled, + ) + + if not updated_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found or update failed.", + ) + + logger.info( + f"User {current_user.id} updated notification channels", + user_id=str(current_user.id), + email_enabled=settings.email_enabled, + telegram_enabled=settings.telegram_enabled, + ) + + return NotificationChannelsResponse( + email_enabled=updated_user.email_notifications_enabled, + telegram_enabled=updated_user.telegram_notifications_enabled, + ) diff --git a/src/static/admiral/assets/auth.scss b/src/static/admiral/assets/auth.scss index 0386a80..b2e9ea3 100644 --- a/src/static/admiral/assets/auth.scss +++ b/src/static/admiral/assets/auth.scss @@ -43,7 +43,7 @@ [data-theme="dark"] { .auth-container { - background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%); } .theme-toggle { @@ -56,7 +56,7 @@ } .auth-title { - color: #fff; + color: #4ecca3; } .auth-label { @@ -64,17 +64,22 @@ } .auth-input { - background: #2a2a2a; + background: #2a2a2a !important; border-color: #444; - color: #fff; + color: #fff !important; &::placeholder { color: #666; } &:focus { - border-color: #667eea; - background: #333; + border-color: #4ecca3; + background: #2a2a2a !important; + } + + &:disabled { + background: #2a2a2a !important; + opacity: 0.6; } } @@ -89,9 +94,39 @@ } .auth-success { - background-color: #1a3d1a; - color: #51cf66; - border-color: #2a5a2a; + background-color: #1a3d2e; + color: #4ecca3; + border-color: #2a5a47; + } + + .auth-button-primary { + background: linear-gradient(135deg, #4ecca3 0%, #3ba378 100%); + color: #1a1a1a; + + &:hover { + background: linear-gradient(135deg, #3ba378 0%, #2d8a5f 100%); + } + + &:disabled { + background: linear-gradient(135deg, #3a9977 0%, #2d7a5f 100%); + opacity: 0.6; + cursor: not-allowed; + } + } + + .auth-button-secondary { + background: transparent; + color: #4ecca3; + border: 2px solid #4ecca3; + + &:hover { + background: #4ecca3; + color: #1a1a1a; + } + } + + .auth-footer { + border-top-color: #333; } } diff --git a/src/static/admiral/pages/notifications/index.tsx b/src/static/admiral/pages/notifications/index.tsx new file mode 100644 index 0000000..f08dd84 --- /dev/null +++ b/src/static/admiral/pages/notifications/index.tsx @@ -0,0 +1,313 @@ +import React, { useState, useEffect } from "react" +import { Page, Card, Button } from "@devfamily/admiral" +import axios from "axios" + +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1" + +interface NotificationSettings { + channel: "email" | "telegram" + show_transaction_id: boolean + show_amount: boolean + show_timestamp: boolean + show_from_account: boolean + show_to_account: boolean + show_triggered_rules: boolean + show_fraud_probability: boolean + show_location: boolean + show_device_info: boolean +} + +const Notifications: React.FC = () => { + const [channel, setChannel] = useState<"email" | "telegram">("email") + const [settings, setSettings] = useState({ + channel: "email", + show_transaction_id: true, + show_amount: true, + show_timestamp: true, + show_from_account: true, + show_to_account: true, + show_triggered_rules: true, + show_fraud_probability: true, + show_location: true, + show_device_info: true + }) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + const [successMessage, setSuccessMessage] = useState("") + + useEffect(() => { + loadSettings() + }, []) + + const loadSettings = async () => { + try { + setLoading(true) + setError("") + + const token = localStorage.getItem("admiral_global_admin_session_token") + if (!token) { + setError("No authentication token found. Please login again.") + setLoading(false) + return + } + + // Load notification settings from API + // For now, we'll use default settings + // You can implement API call here to fetch user's notification preferences + + setLoading(false) + } catch (err: any) { + setError(err.response?.data?.detail || "Failed to load notification settings.") + console.error("Error loading settings:", err) + setLoading(false) + } + } + + const handleChannelChange = (newChannel: "email" | "telegram") => { + setChannel(newChannel) + setSettings((prev) => ({ ...prev, channel: newChannel })) + } + + const handleToggle = (field: keyof Omit) => { + setSettings((prev) => ({ + ...prev, + [field]: !prev[field] + })) + } + + const handleSave = async () => { + try { + setSaving(true) + setError("") + setSuccessMessage("") + + const token = localStorage.getItem("admiral_global_admin_session_token") + if (!token) { + setError("No authentication token found. Please login again.") + setSaving(false) + return + } + + // Save settings to API + // Implement API call here + await axios.post(`${API_URL}/notifications/settings`, settings, { + headers: { + Authorization: `Bearer ${token}` + } + }) + + setSuccessMessage("Notification settings saved successfully!") + setTimeout(() => setSuccessMessage(""), 3000) + } catch (err: any) { + setError(err.response?.data?.detail || "Failed to save notification settings.") + console.error("Error saving settings:", err) + } finally { + setSaving(false) + } + } + + const renderToggleField = (field: keyof Omit, label: string) => { + return ( +
+ + +
+ ) + } + + return ( + + + + + {loading ? ( +
Loading settings...
+ ) : ( +
+ {successMessage && ( +
+ {successMessage} +
+ )} + {error && ( +
+ {error} +
+ )} + +
+

+ Notification Channel +

+
+ + +
+
+ +
+

+ Notification Content +

+

+ Choose what information to include in your {channel} notifications: +

+ +
+ {renderToggleField("show_transaction_id", "Show Transaction ID")} + {renderToggleField("show_amount", "Show Transaction Amount")} + {renderToggleField("show_timestamp", "Show Transaction Timestamp")} + {renderToggleField("show_from_account", "Show Source Account")} + {renderToggleField("show_to_account", "Show Destination Account")} + {renderToggleField("show_triggered_rules", "Show Triggered Rules")} + {renderToggleField("show_fraud_probability", "Show Fraud Probability")} + {renderToggleField("show_location", "Show Transaction Location")} + {renderToggleField("show_device_info", "Show Device Information")} +
+
+ +
+ +
+
+ )} +
+
+ ) +} + +export default Notifications diff --git a/src/static/admiral/pages/profile/index.tsx b/src/static/admiral/pages/profile/index.tsx index 1490648..a03a9be 100644 --- a/src/static/admiral/pages/profile/index.tsx +++ b/src/static/admiral/pages/profile/index.tsx @@ -168,7 +168,7 @@ const Profile: React.FC = () => { width: "100%", padding: "10px 12px", backgroundColor: "var(--color-bg-default)", - border: "1px solid var(--color-bg-border)", + border: "1px solid #000", borderRadius: "6px", color: "var(--color-typo-primary)", fontSize: "14px", @@ -198,7 +198,7 @@ const Profile: React.FC = () => { width: "100%", padding: "10px 12px", backgroundColor: "var(--color-bg-default)", - border: "1px solid var(--color-bg-border)", + border: "1px solid #000", borderRadius: "6px", color: "var(--color-typo-primary)", fontSize: "14px", diff --git a/src/static/admiral/pages/rules/index.tsx b/src/static/admiral/pages/rules/index.tsx index b37d5aa..1917a1a 100644 --- a/src/static/admiral/pages/rules/index.tsx +++ b/src/static/admiral/pages/rules/index.tsx @@ -59,6 +59,27 @@ const Rules: React.FC = () => { const itemsPerPage = 10 + // Prevent scroll wheel from changing number input values + const preventNumberInputScroll = (e: React.WheelEvent) => { + e.currentTarget.blur() + } + + // Format operator for display + const formatOperator = (operator: string | undefined) => { + if (!operator) return "-" + const operatorMap: Record = { + gt: ">", + lt: "<", + eq: "=", + gte: "≥", + lte: "≤", + ne: "≠", + between: "Between", + not_between: "Not Between" + } + return operatorMap[operator] || operator + } + const showNotification = (message: string, type: "success" | "error") => { const id = Date.now() setNotifications((prev) => [...prev, { id, message, type }]) @@ -303,7 +324,7 @@ const Rules: React.FC = () => { } }) } else if (ruleToEdit.type === "ml") { - const mlKeys = ["model_version", "threshold", "endpoint_url"] + const mlKeys = ["model_version", "threshold", "endpoint_url", "model_file_path"] mlKeys.forEach((key) => { if ( editRuleParams[key] !== undefined && @@ -386,7 +407,9 @@ const Rules: React.FC = () => { const handleRuleTypeChange = (value: string) => { setNewRuleType(value) - setNewRuleParams({}) + // Keep existing params instead of clearing them completely + // This allows users to preserve their input when switching between types + // Note: Backend validation will filter out irrelevant params for each type } const handleSaveRule = async () => { @@ -481,7 +504,7 @@ const Rules: React.FC = () => { }) } else if (newRuleType === "ml") { // Only include ML-specific parameters - const mlKeys = ["model_version", "threshold", "endpoint_url"] + const mlKeys = ["model_version", "threshold", "endpoint_url", "model_file_path"] mlKeys.forEach((key) => { if ( newRuleParams[key] !== undefined && @@ -826,6 +849,7 @@ const Rules: React.FC = () => { Rules Model Version + Model File Threshold Endpoint URL @@ -950,7 +974,9 @@ const Rules: React.FC = () => { {/* ALL possible parameter columns - show "-" for missing values */} {rule.params.max_amount || "-"} {rule.params.min_amount || "-"} - {rule.params.operator || "-"} + + {formatOperator(rule.params.operator)} + {rule.params.time_window || "-"} {rule.params.allowed_hours_start || "-"} @@ -1071,6 +1097,9 @@ const Rules: React.FC = () => { {rule.params.model_version || "-"} + + {rule.params.model_file_path || "-"} + {rule.params.threshold || "-"} {rule.params.endpoint_url || "-"} @@ -1270,11 +1299,12 @@ const Rules: React.FC = () => { type="number" value={newRuleParams.max_amount || ""} onChange={(e: any) => - setNewRuleParams({ - ...newRuleParams, + setNewRuleParams((prev) => ({ + ...prev, max_amount: parseFloat(e.target.value) - }) + })) } + onWheel={preventNumberInputScroll} placeholder="Maximum amount" /> @@ -1283,45 +1313,48 @@ const Rules: React.FC = () => { type="number" value={newRuleParams.min_amount || ""} onChange={(e: any) => - setNewRuleParams({ - ...newRuleParams, + setNewRuleParams((prev) => ({ + ...prev, min_amount: parseFloat(e.target.value) - }) + })) } + onWheel={preventNumberInputScroll} placeholder="Minimum amount" /> - setNewRuleParams({ - ...newRuleParams, + setNewRuleParams((prev) => ({ + ...prev, period: value - }) + })) } style={{ width: "100%" }} + allowClear > 1 Minute 5 Minutes @@ -1506,6 +1549,7 @@ const Rules: React.FC = () => { count: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="Number of transactions in the period" /> @@ -1519,6 +1563,7 @@ const Rules: React.FC = () => { amount_ceiling: parseFloat(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="Maximum sum of transactions in period" /> @@ -1546,6 +1591,7 @@ const Rules: React.FC = () => { unique_recipients: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="Max number of unique recipients in period" /> @@ -1573,6 +1619,7 @@ const Rules: React.FC = () => { velocity_limit: parseFloat(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="Max sum of transactions from one device in period" /> @@ -1592,6 +1639,26 @@ const Rules: React.FC = () => { placeholder="e.g., v2.1" /> + + { + const file = e.target.files?.[0] + if (file) { + setNewRuleParams({ + ...newRuleParams, + model_file_path: file.name + }) + } + }} + /> + {newRuleParams.model_file_path && ( +
+ Selected: {newRuleParams.model_file_path} +
+ )} +
{ threshold: parseFloat(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 0.75" /> @@ -1624,12 +1692,12 @@ const Rules: React.FC = () => { <> @@ -1911,6 +1982,7 @@ const Rules: React.FC = () => { allowed_hours_start: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 9 (9 AM)" /> @@ -1924,6 +1996,7 @@ const Rules: React.FC = () => { allowed_hours_end: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 17 (5 PM)" /> @@ -1952,6 +2025,7 @@ const Rules: React.FC = () => { max_devices_per_account: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 5" /> @@ -1965,6 +2039,7 @@ const Rules: React.FC = () => { max_ips_per_account: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 3" /> @@ -1978,6 +2053,7 @@ const Rules: React.FC = () => { max_velocity_amount: parseFloat(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="Limit of sum transfer in period" /> @@ -1991,6 +2067,7 @@ const Rules: React.FC = () => { max_transaction_types: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 5" /> @@ -2004,6 +2081,7 @@ const Rules: React.FC = () => { max_transactions_per_account: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 100" /> @@ -2017,6 +2095,7 @@ const Rules: React.FC = () => { max_transactions_to_account: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 50" /> @@ -2030,6 +2109,7 @@ const Rules: React.FC = () => { max_transactions_per_ip: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 10" /> @@ -2072,6 +2152,7 @@ const Rules: React.FC = () => { count: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="Number of transactions in the period" /> @@ -2085,6 +2166,7 @@ const Rules: React.FC = () => { amount_ceiling: parseFloat(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="Maximum sum of transactions in period" /> @@ -2112,6 +2194,7 @@ const Rules: React.FC = () => { unique_recipients: parseInt(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="Max number of unique recipients in period" /> @@ -2139,6 +2222,7 @@ const Rules: React.FC = () => { velocity_limit: parseFloat(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="Max sum of transactions from one device in period" /> @@ -2159,6 +2243,26 @@ const Rules: React.FC = () => { placeholder="e.g., v2.1" /> + + { + const file = e.target.files?.[0] + if (file) { + setEditRuleParams({ + ...editRuleParams, + model_file_path: file.name + }) + } + }} + /> + {editRuleParams.model_file_path && ( +
+ Current: {editRuleParams.model_file_path} +
+ )} +
{ threshold: parseFloat(e.target.value) }) } + onWheel={preventNumberInputScroll} placeholder="e.g., 0.75" /> @@ -2229,6 +2334,7 @@ const Rules: React.FC = () => { type="number" value={editRulePriority} onChange={(e: any) => setEditRulePriority(parseInt(e.target.value))} + onWheel={preventNumberInputScroll} placeholder="e.g., 5" /> diff --git a/src/static/admiral/pages/transactions/index.tsx b/src/static/admiral/pages/transactions/index.tsx index c014530..3dc96e1 100644 --- a/src/static/admiral/pages/transactions/index.tsx +++ b/src/static/admiral/pages/transactions/index.tsx @@ -1,9 +1,15 @@ import React, { useState, useMemo, useEffect } from "react" -import { Page, Card, Button } from "@devfamily/admiral" +import { Page, Card, Button, Form, Input, Switch } from "@devfamily/admiral" import axios from "axios" const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1" +interface Notification { + id: number + message: string + type: "success" | "error" +} + interface Transaction { id: string amount: number @@ -35,9 +41,25 @@ const Transactions: React.FC = () => { const [loading, setLoading] = useState(true) const [error, setError] = useState("") const [creating, setCreating] = useState(false) + const [isReviewModalOpen, setIsReviewModalOpen] = useState(false) + const [transactionToReview, setTransactionToReview] = useState(null) + const [reviewStatus, setReviewStatus] = useState(true) // true = accepted, false = rejected + const [reviewComment, setReviewComment] = useState("") + const [submittingReview, setSubmittingReview] = useState(false) + const [notifications, setNotifications] = useState([]) const itemsPerPage = 10 + const showNotification = (message: string, type: "success" | "error") => { + const id = Date.now() + setNotifications((prev) => [...prev, { id, message, type }]) + + // Auto-remove notification after 3 seconds + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)) + }, 3000) + } + useEffect(() => { fetchTransactions() }, []) @@ -129,14 +151,79 @@ const Transactions: React.FC = () => { ) await fetchTransactions() + showNotification("Transaction created with test status for review feature", "success") } catch (err: any) { - setError(err.response?.data?.detail || "Failed to create transaction.") + showNotification(err.response?.data?.detail || "Failed to create transaction.", "error") console.error("Error creating transaction:", err) } finally { setCreating(false) } } + const handleOpenReviewModal = (transaction: Transaction) => { + setTransactionToReview(transaction) + setReviewStatus(true) // Default to accepted + setReviewComment("") + setIsReviewModalOpen(true) + } + + const handleCloseReviewModal = () => { + setIsReviewModalOpen(false) + setTransactionToReview(null) + setReviewStatus(true) + setReviewComment("") + } + + const handleSubmitReview = async () => { + if (!transactionToReview) return + + try { + setSubmittingReview(true) + + const token = localStorage.getItem("admiral_global_admin_session_token") + + if (!token) { + showNotification("No authentication token found. Please login again.", "error") + return + } + + await axios.post( + `${API_URL}/transactions/${transactionToReview.id}/review`, + { + status: reviewStatus ? "accepted" : "rejected", + comment: reviewComment + }, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + } + } + ) + + // Update the transaction in the local state + setAllTransactions((prev) => + prev.map((t) => + t.id === transactionToReview.id + ? { ...t, status: reviewStatus ? "APPROVED" : "REJECTED" } + : t + ) + ) + + showNotification( + `Transaction ${reviewStatus ? "accepted" : "rejected"} successfully!`, + "success" + ) + + handleCloseReviewModal() + } catch (err: any) { + showNotification(err.response?.data?.detail || "Failed to submit review.", "error") + console.error("Error submitting review:", err) + } finally { + setSubmittingReview(false) + } + } + const totalPages = Math.ceil(allTransactions.length / itemsPerPage) const paginatedTransactions = useMemo(() => { const startIndex = (currentPage - 1) * itemsPerPage @@ -194,7 +281,90 @@ const Transactions: React.FC = () => { return ( + {/* Notifications */} +
+ {notifications.map((notification) => ( +
+
+ {notification.type === "success" ? "✓" : "✕"} +
+ {notification.message} + +
+ ))} +
+ + {loading ? (
Loading transactions...
) : error ? ( @@ -229,13 +399,44 @@ const Transactions: React.FC = () => { {paginatedTransactions.map((transaction) => ( { + if ( + transaction.status.toUpperCase() === "FLAGGED" || + transaction.status.toUpperCase() === "FAILED" + ) { + handleOpenReviewModal(transaction) + } + }} style={{ borderBottom: "1px solid #eee", backgroundColor: - transaction.status === "FLAGGED" + transaction.status.toUpperCase() === "FLAGGED" ? "rgba(255, 152, 0, 0.15)" : "transparent", - borderLeft: transaction.status === "FLAGGED" ? "4px solid #ff9800" : "none" + borderLeft: + transaction.status.toUpperCase() === "FLAGGED" + ? "4px solid #ff9800" + : "none", + cursor: + transaction.status.toUpperCase() === "FLAGGED" || + transaction.status.toUpperCase() === "FAILED" + ? "pointer" + : "default", + transition: "background-color 0.2s" + }} + onMouseEnter={(e) => { + if ( + transaction.status.toUpperCase() === "FLAGGED" || + transaction.status.toUpperCase() === "FAILED" + ) { + e.currentTarget.style.backgroundColor = "rgba(255, 152, 0, 0.25)" + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = + transaction.status.toUpperCase() === "FLAGGED" + ? "rgba(255, 152, 0, 0.15)" + : "transparent" }} > @@ -252,21 +453,24 @@ const Transactions: React.FC = () => { padding: "4px 8px", borderRadius: "4px", fontSize: "12px", - fontWeight: transaction.status === "FLAGGED" ? "bold" : "normal", + fontWeight: + transaction.status.toUpperCase() === "FLAGGED" ? "bold" : "normal", backgroundColor: - transaction.status === "COMPLETED" + transaction.status.toUpperCase() === "COMPLETED" || + transaction.status.toUpperCase() === "APPROVED" ? "#d4edda" - : transaction.status === "PENDING" + : transaction.status.toUpperCase() === "PENDING" ? "#fff3cd" - : transaction.status === "FLAGGED" + : transaction.status.toUpperCase() === "FLAGGED" ? "#ff9800" : "#f8d7da", color: - transaction.status === "COMPLETED" + transaction.status.toUpperCase() === "COMPLETED" || + transaction.status.toUpperCase() === "APPROVED" ? "#155724" - : transaction.status === "PENDING" + : transaction.status.toUpperCase() === "PENDING" ? "#856404" - : transaction.status === "FLAGGED" + : transaction.status.toUpperCase() === "FLAGGED" ? "#ffffff" : "#721c24" }} @@ -324,6 +528,116 @@ const Transactions: React.FC = () => { )}
+ + {/* Review Modal */} + {isReviewModalOpen && transactionToReview && ( +
+
e.stopPropagation()} + > +

+ Review Transaction +

+ +
+

+ Transaction ID: {transactionToReview.id} +

+

+ Amount: {transactionToReview.currency}{" "} + {transactionToReview.amount.toFixed(2)} +

+

+ From: {transactionToReview.from_account} +

+

+ To: {transactionToReview.to_account} +

+

+ Status: {transactionToReview.status} +

+
+ +
{ + e.preventDefault() + handleSubmitReview() + }} + > + +
+ Reject + setReviewStatus(checked)} /> + Accept +
+
+ + + setReviewComment(e.target.value)} + placeholder="Enter your review comment" + required + /> + + +
+ + +
+
+
+
+ )}
) } diff --git a/src/static/admiral/src/App.tsx b/src/static/admiral/src/App.tsx index d52b94d..25f5aee 100644 --- a/src/static/admiral/src/App.tsx +++ b/src/static/admiral/src/App.tsx @@ -36,7 +36,7 @@ function App() { return ( diff --git a/src/static/admiral/src/config/authProvider.ts b/src/static/admiral/src/config/authProvider.ts index 9d715e9..ad36c7a 100644 --- a/src/static/admiral/src/config/authProvider.ts +++ b/src/static/admiral/src/config/authProvider.ts @@ -56,7 +56,7 @@ const authProvider = (apiUrl: string): AuthProvider => ({ .then((user) => { return { ...user, - fullName: user.name || user.email, + fullName: user.name || user.telegram_alias || "User", tg_alias: user.telegram_alias || user.tg_alias || user.tgAlias } }) diff --git a/src/static/admiral/src/config/menu.tsx b/src/static/admiral/src/config/menu.tsx index 0be769a..70e862f 100644 --- a/src/static/admiral/src/config/menu.tsx +++ b/src/static/admiral/src/config/menu.tsx @@ -31,21 +31,13 @@ const CustomMenu = () => { return () => observer.disconnect() }, []) - const handleLogout = () => { - // Demo mode - redirect to login page - window.location.href = "/login" - } - return ( + - -
- -
) } diff --git a/src/static/admiral/src/config/theme/_color/_Theme_color_themeDark.css b/src/static/admiral/src/config/theme/_color/_Theme_color_themeDark.css index 665a919..4b991fb 100644 --- a/src/static/admiral/src/config/theme/_color/_Theme_color_themeDark.css +++ b/src/static/admiral/src/config/theme/_color/_Theme_color_themeDark.css @@ -2,12 +2,12 @@ /* Базовые цвета, от которых выстраивается вся палитра */ --color-base-dark-base: #fff; /* Базовый цвет содержимого, от которого выстраиваются цвета текста, иконок */ --color-base-dark-essential: #22272b; /* Базовый цвет поверхностей */ - --color-base-dark-project: #86ce2c; /* Проектный цвет, от которого выстраивают акцентные состояния */ + --color-base-dark-project: #14b8a6; /* Проектный цвет, от которого выстраивают акцентные состояния */ --color-base-dark-phantom: #efecf9; /* Тонирующий цвет, от которого выстраиватся бордеры, оверлей под модальными окнами */ - --color-base-dark-path: #90db32; /* Цвет ссылок и контролов, от которого выстраиваются все их состояния */ + --color-base-dark-path: #10b981; /* Цвет ссылок и контролов, от которого выстраиваются все их состояния */ --color-base-dark-system: #4b5963; /* Цвет системнный */ - --color-base-dark-success: #43c758; /* Цвет успеха */ + --color-base-dark-success: #10b981; /* Цвет успеха */ --color-base-dark-alert: #f54d4d; /* Цвет ошибки */ --color-base-dark-warning: #f38b00; /* Цвет предупреждения */ --color-base-dark-caution: #f2c94c; /* Цвет осторожности */ diff --git a/src/static/admiral/yarn.lock b/src/static/admiral/yarn.lock index eeb0e42..4e9c9f3 100644 --- a/src/static/admiral/yarn.lock +++ b/src/static/admiral/yarn.lock @@ -16,7 +16,7 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz" integrity sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw== -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.14.8", "@babel/core@^7.25.2": +"@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.14.8", "@babel/core@^7.25.2", "@babel/core@^7.8.0": version "7.28.4" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz" integrity sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA== @@ -397,7 +397,7 @@ dependencies: tslib "^2.0.0" -"@dnd-kit/core@^5.0.3": +"@dnd-kit/core@^5.0.2", "@dnd-kit/core@^5.0.3": version "5.0.3" resolved "https://registry.npmjs.org/@dnd-kit/core/-/core-5.0.3.tgz" integrity sha512-IribcBLsaPqHdYLpc5xG0TqwYIaD+65offdEYxoKh38WDjzRxUjDmrt7hj9oa/ooUKL0epux20u+mBTd92i/zw== @@ -460,6 +460,11 @@ dependencies: "@codexteam/icons" "^0.0.4" +"@esbuild/linux-loong64@0.14.54": + version "0.14.54" + resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz" + integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw== + "@icons/material@^0.2.4": version "0.2.4" resolved "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz" @@ -790,6 +795,24 @@ "@react-spring/shared" "~9.6.1" "@react-spring/types" "~9.6.1" +"@react-three/fiber@>=6.0": + version "9.4.0" + resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz" + integrity sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g== + dependencies: + "@babel/runtime" "^7.17.8" + "@types/react-reconciler" "^0.32.0" + "@types/webxr" "*" + base64-js "^1.5.1" + buffer "^6.0.3" + its-fine "^2.0.0" + react-reconciler "^0.31.0" + react-use-measure "^2.1.7" + scheduler "^0.25.0" + suspend-react "^0.1.3" + use-sync-external-store "^1.4.0" + zustand "^5.0.3" + "@rollup/pluginutils@^4.1.1": version "4.2.1" resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz" @@ -964,6 +987,13 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^19.1.1", "@types/react@>=18.0.0": + version "19.2.2" + resolved "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz" + integrity sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA== + dependencies: + csstype "^3.0.2" + "@types/scheduler@*": version "0.16.2" resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz" @@ -1211,7 +1241,7 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0: +browserslist@^4.24.0, "browserslist@>= 4.21.0": version "4.27.0" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz" integrity sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw== @@ -1498,11 +1528,106 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.3.4" +esbuild-android-64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz" + integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ== + +esbuild-android-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz" + integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg== + +esbuild-darwin-64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz" + integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug== + +esbuild-darwin-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz" + integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw== + +esbuild-freebsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz" + integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg== + +esbuild-freebsd-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz" + integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q== + +esbuild-linux-32@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz" + integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw== + +esbuild-linux-64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz" + integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg== + +esbuild-linux-arm@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz" + integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw== + esbuild-linux-arm64@0.14.54: version "0.14.54" resolved "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz" integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig== +esbuild-linux-mips64le@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz" + integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw== + +esbuild-linux-ppc64le@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz" + integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ== + +esbuild-linux-riscv64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz" + integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg== + +esbuild-linux-s390x@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz" + integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA== + +esbuild-netbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz" + integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w== + +esbuild-openbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz" + integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw== + +esbuild-sunos-64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz" + integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw== + +esbuild-windows-32@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz" + integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w== + +esbuild-windows-64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz" + integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ== + +esbuild-windows-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz" + integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg== + esbuild@^0.14.27: version "0.14.54" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz" @@ -1666,6 +1791,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@^2.3.2, fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" @@ -2071,6 +2201,11 @@ json5@^2.2.3: resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +konva@^3.2.3, konva@>=2.6: + version "3.4.1" + resolved "https://registry.npmjs.org/konva/-/konva-3.4.1.tgz" + integrity sha512-Lra+Sb9dFwsCtkWoFvtcmVFbzAZCqSS/we3iTxDogBl3DTTjieY0e/1crqvs/EZCNR5uV2Kfvkn7t2547cD1SQ== + leven@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" @@ -2890,7 +3025,7 @@ react-devtools-core@^6.1.5: shell-quote "^1.6.1" ws "^7" -react-dom@^17.0.0: +react-dom@*, "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom@^16.8.5 || ^17.0.0 || ^18.0.0", react-dom@^17.0.0, "react-dom@^18.0.0 || ^17.0.1 || ^16.7.0", react-dom@^19.0.0, "react-dom@>= 16.8.0", react-dom@>=16.0.0, react-dom@>=16.11.0, react-dom@>=16.13, react-dom@>=16.6.0, react-dom@>=16.8, react-dom@>=16.8.0, react-dom@>=16.9.0, react-dom@16.8.x: version "17.0.2" resolved "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz" integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== @@ -2936,11 +3071,60 @@ react-is@^18.0.0: resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +"react-konva@^16.8.0 || ^17.0.0": + version "16.8.6" + resolved "https://registry.npmjs.org/react-konva/-/react-konva-16.8.6.tgz" + integrity sha512-6KRIqHyJuTTMuAehDIXvw+ZrtEj2aMc2fwolhmFlg1HBzH4PJimsMByTcEx292Afh9d38TcHdjXP1C58qqDOlg== + dependencies: + react-reconciler "^0.20.4" + scheduler "^0.13.6" + react-merge-refs@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-1.1.0.tgz" integrity sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ== +react-native@*, react-native@>=0.58, react-native@>=0.78: + version "0.82.1" + resolved "https://registry.npmjs.org/react-native/-/react-native-0.82.1.tgz" + integrity sha512-tFAqcU7Z4g49xf/KnyCEzI4nRTu1Opcx05Ov2helr8ZTg1z7AJR/3sr2rZ+AAVlAs2IXk+B0WOxXGmdD3+4czA== + dependencies: + "@jest/create-cache-key-function" "^29.7.0" + "@react-native/assets-registry" "0.82.1" + "@react-native/codegen" "0.82.1" + "@react-native/community-cli-plugin" "0.82.1" + "@react-native/gradle-plugin" "0.82.1" + "@react-native/js-polyfills" "0.82.1" + "@react-native/normalize-colors" "0.82.1" + "@react-native/virtualized-lists" "0.82.1" + abort-controller "^3.0.0" + anser "^1.4.9" + ansi-regex "^5.0.0" + babel-jest "^29.7.0" + babel-plugin-syntax-hermes-parser "0.32.0" + base64-js "^1.5.1" + commander "^12.0.0" + flow-enums-runtime "^0.0.6" + glob "^7.1.1" + hermes-compiler "0.0.0" + invariant "^2.2.4" + jest-environment-node "^29.7.0" + memoize-one "^5.0.0" + metro-runtime "^0.83.1" + metro-source-map "^0.83.1" + nullthrows "^1.1.1" + pretty-format "^29.7.0" + promise "^8.3.0" + react-devtools-core "^6.1.5" + react-refresh "^0.14.0" + regenerator-runtime "^0.13.2" + scheduler "0.26.0" + semver "^7.1.3" + stacktrace-parser "^0.1.10" + whatwg-fetch "^3.0.0" + ws "^6.2.3" + yargs "^17.6.2" + react-reconciler@^0.20.4: version "0.20.4" resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.20.4.tgz" @@ -3044,7 +3228,16 @@ react-use-measure@^2.1.7: resolved "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz" integrity sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg== -react@^17.0.0: +react-zdog@>=1.0: + version "1.2.2" + resolved "https://registry.npmjs.org/react-zdog/-/react-zdog-1.2.2.tgz" + integrity sha512-Ix7ALha91aOEwiHuxumCeYbARS5XNpc/w0v145oGkM6poF/CvhKJwzLhM5sEZbtrghMA+psAhOJkCTzJoseicA== + dependencies: + react "^18.2.0" + react-dom "^18.2.0" + resize-observer-polyfill "^1.5.1" + +react@*, react@^16.0.0, "react@^16.8.0 || >=17.0.0 || >=18.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.3 || ^17 || ^18", "react@^16.8.5 || ^17.0.0 || ^18.0.0", react@^17.0.0, "react@^18.0.0 || ^17.0.1 || ^16.7.0", react@^19.0.0, react@^19.1.1, "react@>= 16.8", "react@>= 16.8.0", react@>=15, react@>=16.0.0, react@>=16.11.0, react@>=16.13, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=16.9.0, react@>=17.0, react@>=18.0.0, react@16.8.x, react@17.0.2: version "17.0.2" resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz" integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== @@ -3052,7 +3245,7 @@ react@^17.0.0: loose-envify "^1.1.0" object-assign "^4.1.1" -react@^18.2.0: +react@^18.2.0, react@^18.3.1: version "18.3.1" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== @@ -3128,7 +3321,7 @@ rimraf@^3.0.2: optionalDependencies: fsevents "~2.3.2" -sass@^1.49.9: +sass@*, sass@^1.49.9: version "1.57.1" resolved "https://registry.npmjs.org/sass/-/sass-1.57.1.tgz" integrity sha512-O2+LwLS79op7GI0xZ8fqzF7X2m/m8WFfI02dHOdsK5R2ECeS5F62zrwg/relM1rjSLy7Vd/DiMNIvPrQGsA0jw== @@ -3391,6 +3584,11 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +three@>=0.126, three@>=0.156: + version "0.180.0" + resolved "https://registry.npmjs.org/three/-/three-0.180.0.tgz" + integrity sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w== + throat@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz" @@ -3505,7 +3703,7 @@ use-memo-one@^1.1.1: resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz" integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== -use-sync-external-store@^1.4.0: +use-sync-external-store@^1.4.0, use-sync-external-store@>=1.2.0: version "1.6.0" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== @@ -3628,6 +3826,11 @@ yargs@^17.6.2: y18n "^5.0.5" yargs-parser "^21.1.1" +zdog@>=1.0: + version "1.1.3" + resolved "https://registry.npmjs.org/zdog/-/zdog-1.1.3.tgz" + integrity sha512-raRj6r0gPzopFm5XWBJZr/NuV4EEnT4iE+U3dp5FV5pCb588Gmm3zLIp/j9yqqcMiHH8VNQlerLTgOqL7krh6w== + zustand@^5.0.3: version "5.0.8" resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz"