progress in login/register reload password

This commit is contained in:
Hesabix 2025-09-15 21:50:09 +03:30
parent 3fc52556a5
commit f00d2eca6d
46 changed files with 1709 additions and 43 deletions

View file

@ -0,0 +1,90 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.responses import success_response
from app.services.captcha_service import create_captcha
from app.services.auth_service import register_user, login_user, create_password_reset, reset_password
from .schemas import RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest, CreateApiKeyRequest
from app.core.auth_dependency import get_current_user, AuthContext
from app.services.api_key_service import list_personal_keys, create_personal_key, revoke_key
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/captcha", summary="Generate numeric captcha")
def generate_captcha(db: Session = Depends(get_db)) -> dict:
captcha_id, image_base64, ttl = create_captcha(db)
return success_response({
"captcha_id": captcha_id,
"image_base64": image_base64,
"ttl_seconds": ttl,
})
@router.post("/register", summary="Register new user")
def register(payload: RegisterRequest, db: Session = Depends(get_db)) -> dict:
user_id = register_user(
db=db,
first_name=payload.first_name,
last_name=payload.last_name,
email=payload.email,
mobile=payload.mobile,
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
)
return success_response({"user_id": user_id})
@router.post("/login", summary="Login with email or mobile")
def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
user_agent = request.headers.get("User-Agent")
ip = request.client.host if request.client else None
api_key, expires_at, user = login_user(
db=db,
identifier=payload.identifier,
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
device_id=payload.device_id,
user_agent=user_agent,
ip=ip,
)
return success_response({"api_key": api_key, "expires_at": expires_at, "user": user})
@router.post("/forgot-password", summary="Create password reset token")
def forgot_password(payload: ForgotPasswordRequest, db: Session = Depends(get_db)) -> dict:
# In production do not return token; send via email/SMS. Here we return for dev/testing.
token = create_password_reset(db=db, identifier=payload.identifier, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code)
return success_response({"ok": True, "token": token if token else None})
@router.post("/reset-password", summary="Reset password with token")
def reset_password_endpoint(payload: ResetPasswordRequest, db: Session = Depends(get_db)) -> dict:
reset_password(db=db, token=payload.token, new_password=payload.new_password, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code)
return success_response({"ok": True})
@router.get("/api-keys", summary="List personal API keys")
def list_keys(ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
items = list_personal_keys(db, ctx.user.id)
return success_response(items)
@router.post("/api-keys", summary="Create personal API key")
def create_key(payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
id_, api_key = create_personal_key(db, ctx.user.id, payload.name, payload.scopes, None)
return success_response({"id": id_, "api_key": api_key})
@router.delete("/api-keys/{key_id}", summary="Revoke API key")
def delete_key(key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
revoke_key(db, ctx.user.id, key_id)
return success_response({"ok": True})

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from pydantic import BaseModel, EmailStr, Field
class CaptchaSolve(BaseModel):
captcha_id: str = Field(..., min_length=8)
captcha_code: str = Field(..., min_length=3, max_length=8)
class RegisterRequest(CaptchaSolve):
first_name: str | None = Field(default=None, max_length=100)
last_name: str | None = Field(default=None, max_length=100)
email: EmailStr | None = None
mobile: str | None = Field(default=None, max_length=32)
password: str = Field(..., min_length=8, max_length=128)
class LoginRequest(CaptchaSolve):
identifier: str = Field(..., min_length=3, max_length=255)
password: str = Field(..., min_length=8, max_length=128)
device_id: str | None = Field(default=None, max_length=100)
class ForgotPasswordRequest(CaptchaSolve):
identifier: str = Field(..., min_length=3, max_length=255)
class ResetPasswordRequest(CaptchaSolve):
token: str = Field(..., min_length=16)
new_password: str = Field(..., min_length=8, max_length=128)
class CreateApiKeyRequest(BaseModel):
name: str | None = Field(default=None, max_length=100)
scopes: str | None = Field(default=None, max_length=500)
expires_at: str | None = None # ISO string; parse server-side if provided

View file

@ -0,0 +1,9 @@
from adapters.db.session import Base # re-export Base for Alembic
# Import models to register with SQLAlchemy metadata
from .user import User # noqa: F401
from .api_key import ApiKey # noqa: F401
from .captcha import Captcha # noqa: F401
from .password_reset import PasswordReset # noqa: F401

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
key_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
key_type: Mapped[str] = mapped_column(String(16), nullable=False) # "session" | "personal"
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
scopes: Mapped[str | None] = mapped_column(String(500), nullable=True)
device_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,20 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class Captcha(Base):
__tablename__ = "captchas"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
code_hash: Mapped[str] = mapped_column(String(128), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
attempts: Mapped[int] = mapped_column(default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class PasswordReset(Base):
__tablename__ = "password_resets"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
mobile: Mapped[str | None] = mapped_column(String(32), unique=True, index=True, nullable=True)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from adapters.db.models.api_key import ApiKey
class ApiKeyRepository:
def __init__(self, db: Session) -> None:
self.db = db
def create_session_key(self, *, user_id: int, key_hash: str, device_id: str | None, user_agent: str | None, ip: str | None, expires_at: datetime | None) -> ApiKey:
obj = ApiKey(user_id=user_id, key_hash=key_hash, key_type="session", name=None, scopes=None, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def get_by_hash(self, key_hash: str) -> Optional[ApiKey]:
stmt = select(ApiKey).where(ApiKey.key_hash == key_hash)
return self.db.execute(stmt).scalars().first()

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from adapters.db.models.password_reset import PasswordReset
class PasswordResetRepository:
def __init__(self, db: Session) -> None:
self.db = db
def create(self, *, user_id: int, token_hash: str, expires_at: datetime) -> PasswordReset:
obj = PasswordReset(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def get_by_hash(self, token_hash: str) -> Optional[PasswordReset]:
stmt = select(PasswordReset).where(PasswordReset.token_hash == token_hash)
return self.db.execute(stmt).scalars().first()
def mark_used(self, pr: PasswordReset) -> None:
pr.used_at = datetime.utcnow()
self.db.add(pr)
self.db.commit()

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from adapters.db.models.user import User
class UserRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_email(self, email: str) -> Optional[User]:
stmt = select(User).where(User.email == email)
return self.db.execute(stmt).scalars().first()
def get_by_mobile(self, mobile: str) -> Optional[User]:
stmt = select(User).where(User.mobile == mobile)
return self.db.execute(stmt).scalars().first()
def create(self, *, email: str | None, mobile: str | None, password_hash: str, first_name: str | None, last_name: str | None) -> User:
user = User(email=email, mobile=mobile, password_hash=password_hash, first_name=first_name, last_name=last_name)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return user

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Optional
from fastapi import Depends, Header
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.repositories.api_key_repo import ApiKeyRepository
from adapters.db.models.user import User
from app.core.security import hash_api_key
from app.core.responses import ApiError
class AuthContext:
def __init__(self, user: User, api_key_id: int) -> None:
self.user = user
self.api_key_id = api_key_id
def get_current_user(authorization: Optional[str] = Header(default=None), db: Session = Depends(get_db)) -> AuthContext:
if not authorization or not authorization.startswith("ApiKey "):
raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401)
api_key = authorization[len("ApiKey ") :].strip()
key_hash = hash_api_key(api_key)
repo = ApiKeyRepository(db)
obj = repo.get_by_hash(key_hash)
if not obj or obj.revoked_at is not None:
raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401)
from adapters.db.models.user import User
user = db.get(User, obj.user_id)
if not user or not user.is_active:
raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401)
return AuthContext(user=user, api_key_id=obj.id)

View file

@ -0,0 +1,83 @@
from __future__ import annotations
from typing import Any
from fastapi import FastAPI, Request, HTTPException
from fastapi.exceptions import RequestValidationError
from starlette.responses import JSONResponse
def _translate_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
translator = getattr(request.state, "translator", None)
if translator is None:
# fallback
return JSONResponse(
status_code=422,
content={"success": False, "error": {"code": "VALIDATION_ERROR", "message": "Validation error", "details": exc.errors()}},
)
details: list[dict[str, Any]] = []
for err in exc.errors():
type_ = err.get("type")
loc = err.get("loc", [])
ctx = err.get("ctx", {}) or {}
msg = err.get("msg", "")
if type_ == "string_too_short":
msg = translator.t("STRING_TOO_SHORT")
min_len = ctx.get("min_length")
if min_len is not None:
msg = f"{msg} (حداقل {min_len})"
elif type_ == "string_too_long":
msg = translator.t("STRING_TOO_LONG")
max_len = ctx.get("max_length")
if max_len is not None:
msg = f"{msg} (حداکثر {max_len})"
elif type_ in {"missing", "value_error.missing"}:
msg = translator.t("FIELD_REQUIRED")
elif type_ in {"value_error.email", "email", "value_error.email"}:
msg = translator.t("INVALID_EMAIL")
details.append({"loc": loc, "msg": msg, "type": type_})
return JSONResponse(
status_code=422,
content={
"success": False,
"error": {
"code": "VALIDATION_ERROR",
"message": translator.t("VALIDATION_ERROR"),
"details": details,
},
},
)
def _translate_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
translator = getattr(request.state, "translator", None)
detail = exc.detail
status_code = exc.status_code or 400
if isinstance(detail, dict) and isinstance(detail.get("error"), dict):
error = detail["error"]
code = error.get("code")
message = error.get("message")
if translator is not None and isinstance(code, str):
localized = translator.t(code, default=message if isinstance(message, str) else None)
detail["error"]["message"] = localized
return JSONResponse(status_code=status_code, content=detail)
# fallback generic shape
message = ""
if isinstance(detail, str):
message = detail
elif isinstance(detail, dict) and "detail" in detail:
message = str(detail["detail"])
if translator is not None:
message = translator.t("HTTP_ERROR", default=message)
return JSONResponse(status_code=status_code, content={"success": False, "error": {"code": "HTTP_ERROR", "message": message}})
def register_error_handlers(app: FastAPI) -> None:
app.add_exception_handler(RequestValidationError, _translate_validation_error)
app.add_exception_handler(HTTPException, _translate_http_exception)

View file

@ -0,0 +1,89 @@
from __future__ import annotations
from typing import Any, Callable
from fastapi import Request
from .i18n_catalog import get_gettext_translation
SUPPORTED_LOCALES: tuple[str, ...] = ("fa", "en")
DEFAULT_LOCALE: str = "en"
def negotiate_locale(accept_language: str | None) -> str:
if not accept_language:
return DEFAULT_LOCALE
parts = [p.strip() for p in accept_language.split(",") if p.strip()]
for part in parts:
lang = part.split(";")[0].strip().lower()
base = lang.split("-")[0]
if lang in SUPPORTED_LOCALES:
return lang
if base in SUPPORTED_LOCALES:
return base
return DEFAULT_LOCALE
class Translator:
def __init__(self, locale: str) -> None:
self.locale = locale if locale in SUPPORTED_LOCALES else DEFAULT_LOCALE
self._gt = get_gettext_translation(self.locale)
_catalog: dict[str, dict[str, str]] = {
"en": {
"OK": "OK",
"INVALID_CAPTCHA": "Invalid captcha code.",
"INVALID_CREDENTIALS": "Invalid credentials.",
"IDENTIFIER_REQUIRED": "Identifier is required.",
"INVALID_IDENTIFIER": "Identifier must be a valid email or mobile number.",
"EMAIL_IN_USE": "Email is already in use.",
"MOBILE_IN_USE": "Mobile number is already in use.",
"ACCOUNT_DISABLED": "Your account is disabled.",
"RESET_TOKEN_INVALID_OR_EXPIRED": "Reset token is invalid or expired.",
"VALIDATION_ERROR": "Validation error",
"STRING_TOO_SHORT": "String is too short",
"STRING_TOO_LONG": "String is too long",
"FIELD_REQUIRED": "Field is required",
"INVALID_EMAIL": "Invalid email address",
"HTTP_ERROR": "Request failed",
},
"fa": {
"OK": "باشه",
"INVALID_CAPTCHA": "کد امنیتی نامعتبر است.",
"INVALID_CREDENTIALS": "ایمیل/موبایل یا رمز عبور نادرست است.",
"IDENTIFIER_REQUIRED": "شناسه ورود الزامی است.",
"INVALID_IDENTIFIER": "شناسه باید ایمیل یا شماره موبایل معتبر باشد.",
"EMAIL_IN_USE": "این ایمیل قبلاً استفاده شده است.",
"MOBILE_IN_USE": "این شماره موبایل قبلاً استفاده شده است.",
"ACCOUNT_DISABLED": "حساب کاربری شما غیرفعال است.",
"RESET_TOKEN_INVALID_OR_EXPIRED": "توکن بازنشانی نامعتبر یا منقضی شده است.",
"VALIDATION_ERROR": "خطای اعتبارسنجی",
"STRING_TOO_SHORT": "رشته خیلی کوتاه است",
"STRING_TOO_LONG": "رشته خیلی بلند است",
"FIELD_REQUIRED": "فیلد الزامی است",
"INVALID_EMAIL": "ایمیل نامعتبر است",
"HTTP_ERROR": "درخواست ناموفق بود",
},
}
def t(self, key: str, default: str | None = None) -> str:
# 1) gettext domain (if present)
try:
if self._gt is not None:
msg = self._gt.gettext(key)
if msg and msg != key:
return msg
except Exception:
pass
# 2) in-memory catalog fallback
catalog = self._catalog.get(self.locale) or {}
if key in catalog:
return catalog[key]
return default or key
async def locale_dependency(request: Request) -> Translator:
lang = negotiate_locale(request.headers.get("Accept-Language"))
return Translator(lang)

View file

@ -0,0 +1,17 @@
from __future__ import annotations
import gettext
import os
from functools import lru_cache
from typing import Optional
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
LOCALES_DIR = os.path.join(BASE_DIR, 'locales')
@lru_cache(maxsize=32)
def get_gettext_translation(locale: str, domain: str = 'messages') -> Optional[gettext.NullTranslations]:
try:
return gettext.translation(domain=domain, localedir=LOCALES_DIR, languages=[locale], fallback=True)
except Exception:
return None

View file

@ -0,0 +1,16 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, status
def success_response(data: Any) -> dict[str, Any]:
return {"success": True, "data": data}
class ApiError(HTTPException):
def __init__(self, code: str, message: str, http_status: int = status.HTTP_400_BAD_REQUEST) -> None:
super().__init__(status_code=http_status, detail={"success": False, "error": {"code": code, "message": message}})

View file

@ -0,0 +1,46 @@
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
from datetime import datetime, timedelta
from argon2 import PasswordHasher
from app.core.settings import get_settings
_ph = PasswordHasher()
def hash_password(password: str) -> str:
return _ph.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
_ph.verify(password_hash, password)
return True
except Exception:
return False
def generate_api_key(prefix: str = "ak_live_", length: int = 32) -> tuple[str, str]:
"""Return (public_key, key_hash). Store only key_hash in DB."""
secret = secrets.token_urlsafe(length)
api_key = f"{prefix}{secret}"
settings = get_settings()
key_hash = hashlib.sha256(f"{settings.captcha_secret}:{api_key}".encode("utf-8")).hexdigest()
return api_key, key_hash
def consteq(a: str, b: str) -> bool:
return hmac.compare_digest(a, b)
def hash_api_key(api_key: str) -> str:
settings = get_settings()
return hashlib.sha256(f"{settings.captcha_secret}:{api_key}".encode("utf-8")).hexdigest()

View file

@ -23,6 +23,15 @@ class Settings(BaseSettings):
# Logging
log_level: str = "INFO"
# Captcha / Security
captcha_length: int = 5
captcha_ttl_seconds: int = 180
captcha_secret: str = "change_me_captcha"
reset_password_ttl_seconds: int = 3600
# CORS
cors_allowed_origins: list[str] = ["*"]
@property
def mysql_dsn(self) -> str:
return (

View file

@ -1,8 +1,12 @@
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from app.core.settings import get_settings
from app.core.logging import configure_logging
from adapters.api.v1.health import router as health_router
from adapters.api.v1.auth import router as auth_router
from app.core.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers
def create_app() -> FastAPI:
@ -15,7 +19,26 @@ def create_app() -> FastAPI:
debug=settings.debug,
)
application.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@application.middleware("http")
async def add_locale(request: Request, call_next):
lang = negotiate_locale(request.headers.get("Accept-Language"))
request.state.locale = lang
request.state.translator = Translator(lang)
response = await call_next(request)
return response
application.include_router(health_router, prefix=settings.api_v1_prefix)
application.include_router(auth_router, prefix=settings.api_v1_prefix)
register_error_handlers(application)
@application.get("/")
def read_root() -> dict[str, str]:

View file

@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from adapters.db.repositories.api_key_repo import ApiKeyRepository
from app.core.security import generate_api_key
def list_personal_keys(db: Session, user_id: int) -> list[dict]:
repo = ApiKeyRepository(db)
from adapters.db.models.api_key import ApiKey
stmt = db.query(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.key_type == "personal")
items: list[dict] = []
for row in stmt.all():
items.append({
"id": row.id,
"name": row.name,
"scopes": row.scopes,
"created_at": row.created_at,
"expires_at": row.expires_at,
"revoked_at": row.revoked_at,
})
return items
def create_personal_key(db: Session, user_id: int, name: str | None, scopes: str | None, expires_at: Optional[datetime]) -> tuple[int, str]:
api_key, key_hash = generate_api_key(prefix="ak_personal_")
repo = ApiKeyRepository(db)
obj = repo.create_session_key(user_id=user_id, key_hash=key_hash, device_id=None, user_agent=None, ip=None, expires_at=expires_at)
obj.key_type = "personal"
obj.name = name
obj.scopes = scopes
db.add(obj)
db.commit()
return obj.id, api_key
def revoke_key(db: Session, user_id: int, key_id: int) -> None:
from adapters.db.models.api_key import ApiKey
obj = db.get(ApiKey, key_id)
if not obj or obj.user_id != user_id:
from app.core.responses import ApiError
raise ApiError("NOT_FOUND", "Key not found", http_status=404)
obj.revoked_at = datetime.utcnow()
db.add(obj)
db.commit()

View file

@ -0,0 +1,161 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Optional
import phonenumbers
from sqlalchemy.orm import Session
from adapters.db.repositories.user_repo import UserRepository
from adapters.db.repositories.api_key_repo import ApiKeyRepository
from app.core.security import hash_password, verify_password, generate_api_key, consteq
from app.core.settings import get_settings
from app.services.captcha_service import validate_captcha
from adapters.db.repositories.password_reset_repo import PasswordResetRepository
import hashlib
def _normalize_email(email: str | None) -> str | None:
return email.lower().strip() if email else None
def _normalize_mobile(mobile: str | None) -> str | None:
if not mobile:
return None
# Try parse as international; fallback no region
try:
num = phonenumbers.parse(mobile, None)
if not phonenumbers.is_valid_number(num):
return None
return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164)
except Exception:
return None
def _detect_identifier(identifier: str) -> tuple[str, str | None, str | None]:
identifier = identifier.strip()
if "@" in identifier:
return "email", _normalize_email(identifier), None
mobile = _normalize_mobile(identifier)
return ("mobile", None, mobile) if mobile else ("invalid", None, None)
def register_user(*, db: Session, first_name: str | None, last_name: str | None, email: str | None, mobile: str | None, password: str, captcha_id: str, captcha_code: str) -> int:
if not validate_captcha(db, captcha_id, captcha_code):
from app.core.responses import ApiError
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
email_n = _normalize_email(email)
mobile_n = _normalize_mobile(mobile)
if not email_n and not mobile_n:
from app.core.responses import ApiError
raise ApiError("IDENTIFIER_REQUIRED", "Email or mobile is required")
repo = UserRepository(db)
if email_n and repo.get_by_email(email_n):
from app.core.responses import ApiError
raise ApiError("EMAIL_IN_USE", "Email is already in use")
if mobile_n and repo.get_by_mobile(mobile_n):
from app.core.responses import ApiError
raise ApiError("MOBILE_IN_USE", "Mobile is already in use")
pwd_hash = hash_password(password)
user = repo.create(email=email_n, mobile=mobile_n, password_hash=pwd_hash, first_name=first_name, last_name=last_name)
return user.id
def login_user(*, db: Session, identifier: str, password: str, captcha_id: str, captcha_code: str, device_id: str | None, user_agent: str | None, ip: str | None) -> tuple[str, datetime | None, dict]:
if not validate_captcha(db, captcha_id, captcha_code):
from app.core.responses import ApiError
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
kind, email, mobile = _detect_identifier(identifier)
if kind == "invalid":
from app.core.responses import ApiError
raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number")
repo = UserRepository(db)
user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type]
if not user or not verify_password(password, user.password_hash):
from app.core.responses import ApiError
raise ApiError("INVALID_CREDENTIALS", "Invalid credentials")
if not user.is_active:
from app.core.responses import ApiError
raise ApiError("ACCOUNT_DISABLED", "Your account is disabled")
settings = get_settings()
api_key, key_hash = generate_api_key()
expires_at = None # could be set from settings later
api_repo = ApiKeyRepository(db)
api_repo.create_session_key(user_id=user.id, key_hash=key_hash, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at)
user_data = {
"id": user.id,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"mobile": user.mobile,
}
return api_key, expires_at, user_data
def _hash_reset_token(token: str) -> str:
settings = get_settings()
return hashlib.sha256(f"{settings.captcha_secret}:{token}".encode("utf-8")).hexdigest()
def create_password_reset(*, db: Session, identifier: str, captcha_id: str, captcha_code: str) -> str:
if not validate_captcha(db, captcha_id, captcha_code):
from app.core.responses import ApiError
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
kind, email, mobile = _detect_identifier(identifier)
if kind == "invalid":
from app.core.responses import ApiError
raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number")
repo = UserRepository(db)
user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type]
# Always respond OK to avoid user enumeration; but skip creation if user not found
if not user:
return ""
settings = get_settings()
from secrets import token_urlsafe
token = token_urlsafe(32)
token_hash = _hash_reset_token(token)
expires_at = datetime.utcnow() + timedelta(seconds=settings.reset_password_ttl_seconds)
pr_repo = PasswordResetRepository(db)
pr_repo.create(user_id=user.id, token_hash=token_hash, expires_at=expires_at)
return token
def reset_password(*, db: Session, token: str, new_password: str, captcha_id: str, captcha_code: str) -> None:
if not validate_captcha(db, captcha_id, captcha_code):
from app.core.responses import ApiError
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
pr_repo = PasswordResetRepository(db)
token_hash = _hash_reset_token(token)
pr = pr_repo.get_by_hash(token_hash)
if not pr or pr.expires_at < datetime.utcnow() or pr.used_at is not None:
from app.core.responses import ApiError
raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired")
# Update user password
user_repo = UserRepository(db)
user = user_repo.db.get(type(user_repo).db.registry.mapped_classes['User'], pr.user_id) # not ideal, fallback to direct get
# Safer: direct session get
from adapters.db.models.user import User
user = user_repo.db.get(User, pr.user_id)
if not user:
from app.core.responses import ApiError
raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired")
user.password_hash = hash_password(new_password)
user_repo.db.add(user)
user_repo.db.commit()
pr_repo.mark_used(pr)

View file

@ -0,0 +1,97 @@
from __future__ import annotations
import base64
import io
import os
import secrets
from datetime import datetime, timedelta
from typing import Tuple
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from sqlalchemy.orm import Session
from adapters.db.models.captcha import Captcha
from app.core.settings import get_settings
import hashlib
def _generate_numeric_code(length: int) -> str:
return "".join(str(secrets.randbelow(10)) for _ in range(length))
def _hash_code(code: str, secret: str) -> str:
return hashlib.sha256(f"{secret}:{code}".encode("utf-8")).hexdigest()
def _render_image(code: str, width: int = 140, height: int = 48) -> Image.Image:
bg_color = (245, 246, 248)
img = Image.new("RGB", (width, height), bg_color)
draw = ImageDraw.Draw(img)
try:
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
font = ImageFont.truetype(font_path, 28)
except Exception:
font = ImageFont.load_default()
# Noise lines
for _ in range(3):
xy = [(secrets.randbelow(width), secrets.randbelow(height)) for _ in range(2)]
draw.line(xy, fill=(200, 205, 210), width=1)
# measure text
try:
bbox = draw.textbbox((0, 0), code, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
except Exception:
# fallback approximate
text_w, text_h = (len(code) * 16, 24)
x = (width - text_w) // 2
y = (height - text_h) // 2
# Slight jitter per character
avg_char_w = max(1, text_w // max(1, len(code)))
for idx, ch in enumerate(code):
cx = x + idx * avg_char_w + secrets.randbelow(3)
cy = y + secrets.randbelow(3)
draw.text((cx, cy), ch, font=font, fill=(60, 70, 80))
img = img.filter(ImageFilter.SMOOTH)
return img
def create_captcha(db: Session) -> tuple[str, str, int]:
settings = get_settings()
code = _generate_numeric_code(settings.captcha_length)
code_hash = _hash_code(code, settings.captcha_secret)
captcha_id = f"cpt_{secrets.token_hex(8)}"
expires_at = datetime.utcnow() + timedelta(seconds=settings.captcha_ttl_seconds)
obj = Captcha(id=captcha_id, code_hash=code_hash, expires_at=expires_at, attempts=0)
db.add(obj)
db.commit()
image = _render_image(code)
buf = io.BytesIO()
image.save(buf, format="PNG")
image_base64 = base64.b64encode(buf.getvalue()).decode("ascii")
return captcha_id, image_base64, settings.captcha_ttl_seconds
def validate_captcha(db: Session, captcha_id: str, code: str) -> bool:
settings = get_settings()
obj = db.get(Captcha, captcha_id)
if obj is None:
return False
if obj.expires_at < datetime.utcnow():
return False
provided_hash = _hash_code(code.strip(), settings.captcha_secret)
if secrets.compare_digest(provided_hash, obj.code_hash):
return True
obj.attempts += 1
db.add(obj)
db.commit()
return False

View file

@ -10,9 +10,14 @@ Requires-Dist: uvicorn[standard]>=0.30.0
Requires-Dist: sqlalchemy>=2.0.30
Requires-Dist: pymysql>=1.1.0
Requires-Dist: pydantic>=2.7.0
Requires-Dist: email-validator>=2.0.0.post2
Requires-Dist: pydantic-settings>=2.3.0
Requires-Dist: structlog>=24.1.0
Requires-Dist: alembic>=1.13.2
Requires-Dist: argon2-cffi>=23.1.0
Requires-Dist: pillow>=10.3.0
Requires-Dist: phonenumbers>=8.13.40
Requires-Dist: Babel>=2.15.0
Provides-Extra: dev
Requires-Dist: pytest>=8.2.0; extra == "dev"
Requires-Dist: httpx>=0.27.0; extra == "dev"

View file

@ -3,19 +3,39 @@ pyproject.toml
adapters/__init__.py
adapters/api/__init__.py
adapters/api/v1/__init__.py
adapters/api/v1/auth.py
adapters/api/v1/health.py
adapters/api/v1/schemas.py
adapters/db/__init__.py
adapters/db/session.py
adapters/db/models/__init__.py
adapters/db/models/api_key.py
adapters/db/models/captcha.py
adapters/db/models/password_reset.py
adapters/db/models/user.py
adapters/db/repositories/api_key_repo.py
adapters/db/repositories/password_reset_repo.py
adapters/db/repositories/user_repo.py
app/__init__.py
app/main.py
app/core/__init__.py
app/core/auth_dependency.py
app/core/error_handlers.py
app/core/i18n.py
app/core/i18n_catalog.py
app/core/logging.py
app/core/responses.py
app/core/security.py
app/core/settings.py
app/services/api_key_service.py
app/services/auth_service.py
app/services/captcha_service.py
hesabix_api.egg-info/PKG-INFO
hesabix_api.egg-info/SOURCES.txt
hesabix_api.egg-info/dependency_links.txt
hesabix_api.egg-info/requires.txt
hesabix_api.egg-info/top_level.txt
migrations/env.py
migrations/versions/20250915_000001_init_auth_tables.py
tests/__init__.py
tests/test_health.py

View file

@ -3,9 +3,14 @@ uvicorn[standard]>=0.30.0
sqlalchemy>=2.0.30
pymysql>=1.1.0
pydantic>=2.7.0
email-validator>=2.0.0.post2
pydantic-settings>=2.3.0
structlog>=24.1.0
alembic>=1.13.2
argon2-cffi>=23.1.0
pillow>=10.3.0
phonenumbers>=8.13.40
Babel>=2.15.0
[dev]
pytest>=8.2.0

Binary file not shown.

View file

@ -0,0 +1,61 @@
msgid ""
msgstr ""
"Project-Id-Version: hesabix-api\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-15 00:00+0000\n"
"PO-Revision-Date: 2025-09-15 00:00+0000\n"
"Last-Translator: \n"
"Language-Team: fa\n"
"Language: fa\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
# Common / Errors
msgid "OK"
msgstr "باشه"
msgid "HTTP_ERROR"
msgstr "درخواست ناموفق بود"
msgid "VALIDATION_ERROR"
msgstr "خطای اعتبارسنجی"
msgid "STRING_TOO_SHORT"
msgstr "رشته خیلی کوتاه است"
msgid "STRING_TOO_LONG"
msgstr "رشته خیلی بلند است"
msgid "FIELD_REQUIRED"
msgstr "فیلد الزامی است"
msgid "INVALID_EMAIL"
msgstr "ایمیل نامعتبر است"
# Auth
msgid "INVALID_CAPTCHA"
msgstr "کد امنیتی نامعتبر است."
msgid "INVALID_CREDENTIALS"
msgstr "ایمیل/موبایل یا رمز عبور نادرست است."
msgid "IDENTIFIER_REQUIRED"
msgstr "شناسه ورود الزامی است."
msgid "INVALID_IDENTIFIER"
msgstr "شناسه باید ایمیل یا شماره موبایل معتبر باشد."
msgid "EMAIL_IN_USE"
msgstr "این ایمیل قبلاً استفاده شده است."
msgid "MOBILE_IN_USE"
msgstr "این شماره موبایل قبلاً استفاده شده است."
msgid "ACCOUNT_DISABLED"
msgstr "حساب کاربری شما غیرفعال است."
msgid "RESET_TOKEN_INVALID_OR_EXPIRED"
msgstr "توکن بازنشانی نامعتبر یا منقضی شده است."

View file

@ -7,6 +7,7 @@ from alembic import context
from adapters.db.session import Base
from app.core.settings import get_settings
import adapters.db.models # noqa: F401 # Import models to register metadata
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.

View file

@ -0,0 +1,86 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# revision identifiers, used by Alembic.
revision = "20250915_000001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("mobile", sa.String(length=32), nullable=True),
sa.Column("first_name", sa.String(length=100), nullable=True),
sa.Column("last_name", sa.String(length=100), nullable=True),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
)
op.create_index("ix_users_email", "users", ["email"], unique=True)
op.create_index("ix_users_mobile", "users", ["mobile"], unique=True)
op.create_table(
"api_keys",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("key_hash", sa.String(length=128), nullable=False),
sa.Column("key_type", sa.String(length=16), nullable=False),
sa.Column("name", sa.String(length=100), nullable=True),
sa.Column("scopes", sa.String(length=500), nullable=True),
sa.Column("device_id", sa.String(length=100), nullable=True),
sa.Column("user_agent", sa.String(length=255), nullable=True),
sa.Column("ip", sa.String(length=64), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
)
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"], unique=False)
op.create_table(
"captchas",
sa.Column("id", sa.String(length=40), primary_key=True),
sa.Column("code_hash", sa.String(length=128), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
)
op.create_table(
"password_resets",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token_hash", sa.String(length=128), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
)
op.create_index("ix_password_resets_token_hash", "password_resets", ["token_hash"], unique=True)
op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_index("ix_password_resets_user_id", table_name="password_resets")
op.drop_index("ix_password_resets_token_hash", table_name="password_resets")
op.drop_table("password_resets")
op.drop_table("captchas")
op.drop_index("ix_api_keys_user_id", table_name="api_keys")
op.drop_index("ix_api_keys_key_hash", table_name="api_keys")
op.drop_table("api_keys")
op.drop_index("ix_users_mobile", table_name="users")
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")

View file

@ -14,9 +14,14 @@ dependencies = [
"sqlalchemy>=2.0.30",
"pymysql>=1.1.0",
"pydantic>=2.7.0",
"email-validator>=2.0.0.post2",
"pydantic-settings>=2.3.0",
"structlog>=24.1.0",
"alembic>=1.13.2"
"alembic>=1.13.2",
"argon2-cffi>=23.1.0",
"pillow>=10.3.0",
"phonenumbers>=8.13.40",
"Babel>=2.15.0"
]
[project.optional-dependencies]

View file

@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../config/app_config.dart';
import 'auth_store.dart';
class ApiClientOptions {
final Duration connectTimeout;
@ -19,11 +20,16 @@ class ApiClientOptions {
class ApiClient {
final Dio _dio;
static Locale? _currentLocale;
static AuthStore? _authStore;
static void setCurrentLocale(Locale locale) {
_currentLocale = locale;
}
static void bindAuthStore(AuthStore store) {
_authStore = store;
}
ApiClient._(this._dio);
factory ApiClient({String? baseUrl, ApiClientOptions options = const ApiClientOptions()}) {
@ -47,6 +53,14 @@ class ApiClient {
if (lang != null && lang.isNotEmpty) {
options.headers['Accept-Language'] = lang;
}
final apiKey = _authStore?.apiKey;
if (apiKey != null && apiKey.isNotEmpty) {
options.headers['Authorization'] = 'ApiKey $apiKey';
}
final deviceId = _authStore?.deviceId;
if (deviceId != null && deviceId.isNotEmpty) {
options.headers['X-Device-Id'] = deviceId;
}
if (kDebugMode) {
// ignore: avoid_print
print('[API][REQ] ${options.method} ${options.uri}');

View file

@ -0,0 +1,52 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
class AuthStore with ChangeNotifier {
static const _kApiKey = 'auth_api_key';
static const _kDeviceId = 'device_id';
final FlutterSecureStorage _secure = const FlutterSecureStorage();
String? _apiKey;
String? _deviceId;
String? get apiKey => _apiKey;
String get deviceId => _deviceId ?? '';
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
_deviceId = prefs.getString(_kDeviceId);
if (_deviceId == null || _deviceId!.isEmpty) {
_deviceId = const Uuid().v4();
await prefs.setString(_kDeviceId, _deviceId!);
}
if (kIsWeb) {
_apiKey = prefs.getString(_kApiKey);
} else {
_apiKey = await _secure.read(key: _kApiKey);
_apiKey ??= prefs.getString(_kApiKey);
}
notifyListeners();
}
Future<void> saveApiKey(String? key) async {
final prefs = await SharedPreferences.getInstance();
_apiKey = key;
if (key == null) {
await _secure.delete(key: _kApiKey);
await prefs.remove(_kApiKey);
} else {
if (kIsWeb) {
await prefs.setString(_kApiKey, key);
} else {
await _secure.write(key: _kApiKey, value: key);
await prefs.setString(_kApiKey, key);
}
}
notifyListeners();
}
}

View file

@ -8,8 +8,7 @@
"loginFailed": "Login failed. Please try again.",
"homeWelcome": "Signed in successfully!",
"language": "Language",
"requiredField": "is required"
,
"requiredField": "is required",
"register": "Register",
"forgotPassword": "Forgot password",
"firstName": "First name",
@ -17,8 +16,7 @@
"email": "Email",
"mobile": "Mobile number",
"registerSuccess": "Registration successful.",
"forgotSent": "Reset link sent to your email."
,
"forgotSent": "Reset link sent to your email.",
"identifier": "Email or mobile",
"theme": "Theme",
"system": "System",
@ -26,6 +24,10 @@
"dark": "Dark",
"welcomeTitle": "Hesabix Cloud Accounting",
"welcomeSubtitle": "Smart, secure, and always available accounting for your business.",
"brandTagline": "Manage your finances anywhere, anytime with confidence."
"brandTagline": "Manage your finances anywhere, anytime with confidence.",
"captcha": "Captcha",
"refresh": "Refresh"
,
"captchaRequired": "Captcha is required."
}

View file

@ -8,8 +8,7 @@
"loginFailed": "ورود ناموفق بود. لطفاً دوباره تلاش کنید.",
"homeWelcome": "ورود موفقیت‌آمیز!",
"language": "زبان",
"requiredField": "ضروری است"
,
"requiredField": "ضروری است",
"register": "عضویت",
"forgotPassword": "فراموشی رمز",
"firstName": "نام",
@ -17,8 +16,7 @@
"email": "ایمیل",
"mobile": "شماره موبایل",
"registerSuccess": "عضویت با موفقیت انجام شد.",
"forgotSent": "لینک بازیابی به ایمیل ارسال شد."
,
"forgotSent": "لینک بازیابی به ایمیل ارسال شد.",
"identifier": "ایمیل یا شماره موبایل",
"theme": "تم",
"system": "سیستمی",
@ -26,6 +24,10 @@
"dark": "تیره",
"welcomeTitle": "حسابداری ابری حسابیکس",
"welcomeSubtitle": "حسابداری هوشمند، امن و همیشه در دسترس برای کسب‌وکار شما.",
"brandTagline": "مدیریت مالی هرجا و هر زمان با اطمینان."
"brandTagline": "مدیریت مالی هرجا و هر زمان با اطمینان.",
"captcha": "کد امنیتی",
"refresh": "تازه‌سازی"
,
"captchaRequired": "کد امنیتی الزامی است."
}

View file

@ -247,6 +247,24 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Manage your finances anywhere, anytime with confidence.'**
String get brandTagline;
/// No description provided for @captcha.
///
/// In en, this message translates to:
/// **'Captcha'**
String get captcha;
/// No description provided for @refresh.
///
/// In en, this message translates to:
/// **'Refresh'**
String get refresh;
/// No description provided for @captchaRequired.
///
/// In en, this message translates to:
/// **'Captcha is required.'**
String get captchaRequired;
}
class _AppLocalizationsDelegate

View file

@ -84,4 +84,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get brandTagline =>
'Manage your finances anywhere, anytime with confidence.';
@override
String get captcha => 'Captcha';
@override
String get refresh => 'Refresh';
@override
String get captchaRequired => 'Captcha is required.';
}

View file

@ -83,4 +83,13 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get brandTagline => 'مدیریت مالی هرجا و هر زمان با اطمینان.';
@override
String get captcha => 'کد امنیتی';
@override
String get refresh => 'تازه‌سازی';
@override
String get captchaRequired => 'کد امنیتی الزامی است.';
}

View file

@ -11,6 +11,7 @@ import 'core/locale_controller.dart';
import 'core/api_client.dart';
import 'theme/theme_controller.dart';
import 'theme/app_theme.dart';
import 'core/auth_store.dart';
void main() {
runApp(const MyApp());
@ -26,6 +27,7 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> {
LocaleController? _controller;
ThemeController? _themeController;
AuthStore? _authStore;
@override
void initState() {
@ -51,12 +53,23 @@ class _MyAppState extends State<MyApp> {
});
});
});
final store = AuthStore();
store.load().then((_) {
setState(() {
_authStore = store
..addListener(() {
setState(() {});
});
ApiClient.bindAuthStore(store);
});
});
}
// Root of application with GoRouter
@override
Widget build(BuildContext context) {
if (_controller == null || _themeController == null) {
if (_controller == null || _themeController == null || _authStore == null) {
return const MaterialApp(
home: Scaffold(body: Center(child: CircularProgressIndicator())),
);
@ -74,6 +87,7 @@ class _MyAppState extends State<MyApp> {
builder: (context, state) => LoginPage(
localeController: controller,
themeController: themeController,
authStore: _authStore!,
),
),
GoRoute(

View file

@ -1,18 +1,24 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:dio/dio.dart';
import '../core/api_client.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../core/locale_controller.dart';
import '../widgets/language_switcher.dart';
import '../widgets/theme_mode_switcher.dart';
import '../theme/theme_controller.dart';
import '../widgets/auth_footer.dart';
import '../core/auth_store.dart';
import '../widgets/error_notice.dart';
class LoginPage extends StatefulWidget {
final LocaleController localeController;
final ThemeController? themeController;
const LoginPage({super.key, required this.localeController, this.themeController});
final AuthStore authStore;
const LoginPage({super.key, required this.localeController, this.themeController, required this.authStore});
@override
State<LoginPage> createState() => _LoginPageState();
@ -21,8 +27,12 @@ class LoginPage extends StatefulWidget {
class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
// Login
final _formKey = GlobalKey<FormState>();
final _usernameCtrl = TextEditingController();
final _identifierCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _loginCaptchaCtrl = TextEditingController();
String? _loginCaptchaId;
Uint8List? _loginCaptchaImage;
Timer? _loginCaptchaTimer;
bool _loadingLogin = false;
String? _errorText;
@ -33,29 +43,115 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
final _emailCtrl = TextEditingController();
final _mobileCtrl = TextEditingController();
final _registerPasswordCtrl = TextEditingController();
final _registerCaptchaCtrl = TextEditingController();
String? _registerCaptchaId;
Uint8List? _registerCaptchaImage;
bool _loadingRegister = false;
Timer? _registerCaptchaTimer;
String? _registerErrorText;
// Forgot password
final _forgotKey = GlobalKey<FormState>();
final _forgotEmailCtrl = TextEditingController();
final _forgotIdentifierCtrl = TextEditingController();
final _forgotCaptchaCtrl = TextEditingController();
String? _forgotCaptchaId;
Uint8List? _forgotCaptchaImage;
bool _loadingForgot = false;
Timer? _forgotCaptchaTimer;
String? _forgotErrorText;
@override
void dispose() {
_usernameCtrl.dispose();
_identifierCtrl.dispose();
_passwordCtrl.dispose();
_firstNameCtrl.dispose();
_lastNameCtrl.dispose();
_emailCtrl.dispose();
_mobileCtrl.dispose();
_registerPasswordCtrl.dispose();
_forgotEmailCtrl.dispose();
_registerCaptchaCtrl.dispose();
_forgotIdentifierCtrl.dispose();
_loginCaptchaCtrl.dispose();
_forgotCaptchaCtrl.dispose();
_loginCaptchaTimer?.cancel();
_registerCaptchaTimer?.cancel();
_forgotCaptchaTimer?.cancel();
super.dispose();
}
Future<void> _refreshCaptcha(String scope) async {
final api = ApiClient();
final res = await api.post<Map<String, dynamic>>('/api/v1/auth/captcha');
final data = res.data!['data'] as Map<String, dynamic>;
final id = data['captcha_id'] as String;
final imgB64 = data['image_base64'] as String;
final bytes = base64Decode(imgB64);
final ttl = (data['ttl_seconds'] as num?)?.toInt();
setState(() {
if (scope == 'login') _loginCaptchaId = id;
if (scope == 'register') _registerCaptchaId = id;
if (scope == 'forgot') _forgotCaptchaId = id;
if (scope == 'login') _loginCaptchaImage = bytes;
if (scope == 'register') _registerCaptchaImage = bytes;
if (scope == 'forgot') _forgotCaptchaImage = bytes;
});
if (ttl != null && ttl > 0) {
final delay = Duration(seconds: ttl);
if (scope == 'login') {
_loginCaptchaTimer?.cancel();
_loginCaptchaTimer = Timer(delay, () => _refreshCaptcha('login'));
} else if (scope == 'register') {
_registerCaptchaTimer?.cancel();
_registerCaptchaTimer = Timer(delay, () => _refreshCaptcha('register'));
} else if (scope == 'forgot') {
_forgotCaptchaTimer?.cancel();
_forgotCaptchaTimer = Timer(delay, () => _refreshCaptcha('forgot'));
}
}
}
@override
void initState() {
super.initState();
// پیشبارگذاری کپچا برای هر سه تب
_refreshCaptcha('login');
_refreshCaptcha('register');
_refreshCaptcha('forgot');
}
String _extractErrorMessage(Object e, AppLocalizations t) {
try {
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['error'] is Map && data['error']['message'] is String) {
return data['error']['message'] as String;
}
if (data is Map && data['detail'] is List) {
final details = data['detail'] as List;
if (details.isNotEmpty && details.first is Map && (details.first as Map)['msg'] is String) {
return (details.first as Map)['msg'] as String;
}
}
}
} catch (_) {}
return t.loginFailed;
}
void _showSnack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
Future<void> _onSubmit() async {
final form = _formKey.currentState;
final t = AppLocalizations.of(context);
if (form == null || !form.validate()) return;
if ((_loginCaptchaCtrl.text.trim().isEmpty) || (_loginCaptchaId == null)) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.captchaRequired)));
return;
}
setState(() {
_loadingLogin = true;
@ -64,20 +160,30 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
try {
final api = ApiClient();
await api.post<Map<String, dynamic>>(
'/user/login',
final res = await api.post<Map<String, dynamic>>(
'/api/v1/auth/login',
data: {
'username': _usernameCtrl.text.trim(),
'identifier': _identifierCtrl.text.trim(),
'password': _passwordCtrl.text,
'captcha_id': _loginCaptchaId,
'captcha_code': _loginCaptchaCtrl.text.trim(),
'device_id': widget.authStore.deviceId,
},
);
final data = res.data?['data'] as Map<String, dynamic>?;
final apiKey = data?['api_key'] as String?;
if (apiKey != null && apiKey.isNotEmpty) {
await widget.authStore.saveApiKey(apiKey);
}
if (!mounted) return;
// روی موفقیت ساده به / هدایت میکنیم. در آینده توکن/پروفایل ذخیره میشود.
_showSnack(t.homeWelcome);
context.go('/');
} catch (e) {
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
_showSnack(msg);
setState(() {
_errorText = 'ورود ناموفق بود. لطفاً دوباره تلاش کنید.';
_errorText = msg;
});
} finally {
if (mounted) {
@ -85,6 +191,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
_loadingLogin = false;
});
}
_refreshCaptcha('login');
}
}
@ -96,25 +203,37 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
setState(() => _loadingRegister = true);
try {
final api = ApiClient();
if (_emailCtrl.text.trim().isEmpty && _mobileCtrl.text.trim().isEmpty) {
final msg = '${t.email} / ${t.mobile} ${t.requiredField}';
setState(() { _registerErrorText = msg; });
_showSnack(msg);
return;
}
await api.post<Map<String, dynamic>>(
'/user/register',
'/api/v1/auth/register',
data: {
'first_name': _firstNameCtrl.text.trim(),
'last_name': _lastNameCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'mobile': _mobileCtrl.text.trim(),
'email': _emailCtrl.text.trim().isEmpty ? null : _emailCtrl.text.trim(),
'mobile': _mobileCtrl.text.trim().isEmpty ? null : _mobileCtrl.text.trim(),
'password': _registerPasswordCtrl.text,
'captcha_id': _registerCaptchaId,
'captcha_code': _registerCaptchaCtrl.text.trim(),
},
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.registerSuccess)));
DefaultTabController.of(context)?.animateTo(0);
} catch (_) {
setState(() { _registerErrorText = null; });
_showSnack(t.registerSuccess);
DefaultTabController.of(context).animateTo(0);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.loginFailed)));
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
setState(() { _registerErrorText = msg; });
_showSnack(msg);
} finally {
if (mounted) setState(() => _loadingRegister = false);
_refreshCaptcha('register');
}
}
@ -127,19 +246,24 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
try {
final api = ApiClient();
await api.post<Map<String, dynamic>>(
'/user/forgot-password',
'/api/v1/auth/forgot-password',
data: {
'email': _forgotEmailCtrl.text.trim(),
'identifier': _forgotIdentifierCtrl.text.trim(),
'captcha_id': _forgotCaptchaId,
'captcha_code': _forgotCaptchaCtrl.text.trim(),
},
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.forgotSent)));
} catch (_) {
_showSnack(t.forgotSent);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.loginFailed)));
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
setState(() { _forgotErrorText = msg; });
_showSnack(msg);
} finally {
if (mounted) setState(() => _loadingForgot = false);
_refreshCaptcha('forgot');
}
}
@ -196,7 +320,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _usernameCtrl,
controller: _identifierCtrl,
decoration: InputDecoration(labelText: t.identifier),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.identifier} ${t.requiredField}' : null,
textInputAction: TextInputAction.next,
@ -209,9 +333,42 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
validator: (v) => (v == null || v.isEmpty) ? '${t.password} ${t.requiredField}' : null,
onFieldSubmitted: (_) => _onSubmit(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _loginCaptchaCtrl,
decoration: InputDecoration(labelText: t.captcha),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 8),
if (_loginCaptchaImage != null)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.memory(
_loginCaptchaImage!,
height: 40,
width: 120,
fit: BoxFit.contain,
),
)
else
const SizedBox(height: 40, width: 120),
const SizedBox(width: 8),
IconButton(
onPressed: () => _refreshCaptcha('login'),
icon: const Icon(Icons.refresh),
tooltip: t.refresh,
),
],
),
const SizedBox(height: 16),
if (_errorText != null)
Text(_errorText!, style: const TextStyle(color: Colors.red)),
ErrorNotice(message: _errorText!, onClose: () => setState(() => _errorText = null)),
const SizedBox(height: 12),
FilledButton(
onPressed: _loadingLogin ? null : _onSubmit,
@ -268,7 +425,43 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
validator: (v) => (v == null || v.isEmpty) ? '${t.password} ${t.requiredField}' : null,
onFieldSubmitted: (_) => _onRegister(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _registerCaptchaCtrl,
decoration: InputDecoration(labelText: t.captcha),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 8),
if (_registerCaptchaImage != null)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.memory(
_registerCaptchaImage!,
height: 40,
width: 120,
fit: BoxFit.contain,
),
)
else
const SizedBox(height: 40, width: 120),
const SizedBox(width: 8),
IconButton(
onPressed: () => _refreshCaptcha('register'),
icon: const Icon(Icons.refresh),
tooltip: t.refresh,
),
],
),
const SizedBox(height: 16),
if (_registerErrorText != null)
ErrorNotice(message: _registerErrorText!, onClose: () => setState(() => _registerErrorText = null)),
const SizedBox(height: 12),
FilledButton(
onPressed: _loadingRegister ? null : _onRegister,
child: _loadingRegister
@ -288,13 +481,48 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _forgotEmailCtrl,
decoration: InputDecoration(labelText: t.email),
keyboardType: TextInputType.emailAddress,
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.email} ${t.requiredField}' : null,
controller: _forgotIdentifierCtrl,
decoration: InputDecoration(labelText: t.identifier),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.identifier} ${t.requiredField}' : null,
onFieldSubmitted: (_) => _onForgot(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _forgotCaptchaCtrl,
decoration: InputDecoration(labelText: t.captcha),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 8),
if (_forgotCaptchaImage != null)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.memory(
_forgotCaptchaImage!,
height: 40,
width: 120,
fit: BoxFit.contain,
),
)
else
const SizedBox(height: 40, width: 120),
const SizedBox(width: 8),
IconButton(
onPressed: () => _refreshCaptcha('forgot'),
icon: const Icon(Icons.refresh),
tooltip: t.refresh,
),
],
),
const SizedBox(height: 12),
if (_forgotErrorText != null)
ErrorNotice(message: _forgotErrorText!, onClose: () => setState(() => _forgotErrorText = null)),
const SizedBox(height: 12),
FilledButton(
onPressed: _loadingForgot ? null : _onForgot,
child: _loadingForgot

View file

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
class ErrorNotice extends StatelessWidget {
final String message;
final VoidCallback? onClose;
const ErrorNotice({super.key, required this.message, this.onClose});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final bg = cs.errorContainer;
final fg = cs.onErrorContainer;
return Semantics(
container: true,
liveRegion: true,
label: 'error',
child: Container(
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.error.withOpacity(0.4)),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, color: cs.error, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: TextStyle(color: fg, height: 1.3),
),
),
if (onClose != null)
IconButton(
tooltip: MaterialLocalizations.of(context).closeButtonLabel,
icon: Icon(Icons.close, color: fg),
onPressed: onClose,
visualDensity: VisualDensity.compact,
splashRadius: 16,
),
],
),
),
);
}
}

View file

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
}

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -5,8 +5,12 @@
import FlutterMacOS
import Foundation
import flutter_secure_storage_macos
import path_provider_foundation
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View file

@ -41,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
cupertino_icons:
dependency: "direct main"
description:
@ -89,6 +97,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@ -107,6 +123,54 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter
@ -141,6 +205,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
leak_tracker:
dependency: transitive
description:
@ -221,6 +293,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
url: "https://pub.dev"
source: hosted
version: "2.2.18"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@ -330,6 +426,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace:
dependency: transitive
description:
@ -378,6 +482,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_math:
dependency: transitive
description:
@ -402,6 +514,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev"
source: hosted
version: "5.14.0"
xdg_directories:
dependency: transitive
description:

View file

@ -39,6 +39,8 @@ dependencies:
dio: ^5.7.0
go_router: ^14.2.7
shared_preferences: ^2.3.2
flutter_secure_storage: ^9.2.2
uuid: ^4.4.2
dev_dependencies:
flutter_test:

View file

@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
}

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST