progress in login/register reload password
This commit is contained in:
parent
3fc52556a5
commit
f00d2eca6d
90
hesabixAPI/adapters/api/v1/auth.py
Normal file
90
hesabixAPI/adapters/api/v1/auth.py
Normal 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})
|
||||
|
||||
|
||||
39
hesabixAPI/adapters/api/v1/schemas.py
Normal file
39
hesabixAPI/adapters/api/v1/schemas.py
Normal 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
|
||||
|
||||
|
||||
9
hesabixAPI/adapters/db/models/__init__.py
Normal file
9
hesabixAPI/adapters/db/models/__init__.py
Normal 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
|
||||
|
||||
|
||||
28
hesabixAPI/adapters/db/models/api_key.py
Normal file
28
hesabixAPI/adapters/db/models/api_key.py
Normal 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)
|
||||
|
||||
|
||||
20
hesabixAPI/adapters/db/models/captcha.py
Normal file
20
hesabixAPI/adapters/db/models/captcha.py
Normal 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)
|
||||
|
||||
|
||||
21
hesabixAPI/adapters/db/models/password_reset.py
Normal file
21
hesabixAPI/adapters/db/models/password_reset.py
Normal 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)
|
||||
|
||||
|
||||
24
hesabixAPI/adapters/db/models/user.py
Normal file
24
hesabixAPI/adapters/db/models/user.py
Normal 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)
|
||||
|
||||
|
||||
27
hesabixAPI/adapters/db/repositories/api_key_repo.py
Normal file
27
hesabixAPI/adapters/db/repositories/api_key_repo.py
Normal 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()
|
||||
|
||||
|
||||
30
hesabixAPI/adapters/db/repositories/password_reset_repo.py
Normal file
30
hesabixAPI/adapters/db/repositories/password_reset_repo.py
Normal 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()
|
||||
30
hesabixAPI/adapters/db/repositories/user_repo.py
Normal file
30
hesabixAPI/adapters/db/repositories/user_repo.py
Normal 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
|
||||
|
||||
|
||||
39
hesabixAPI/app/core/auth_dependency.py
Normal file
39
hesabixAPI/app/core/auth_dependency.py
Normal 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)
|
||||
|
||||
|
||||
83
hesabixAPI/app/core/error_handlers.py
Normal file
83
hesabixAPI/app/core/error_handlers.py
Normal 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)
|
||||
|
||||
|
||||
89
hesabixAPI/app/core/i18n.py
Normal file
89
hesabixAPI/app/core/i18n.py
Normal 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)
|
||||
|
||||
|
||||
17
hesabixAPI/app/core/i18n_catalog.py
Normal file
17
hesabixAPI/app/core/i18n_catalog.py
Normal 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
|
||||
16
hesabixAPI/app/core/responses.py
Normal file
16
hesabixAPI/app/core/responses.py
Normal 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}})
|
||||
|
||||
|
||||
46
hesabixAPI/app/core/security.py
Normal file
46
hesabixAPI/app/core/security.py
Normal 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()
|
||||
|
||||
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
51
hesabixAPI/app/services/api_key_service.py
Normal file
51
hesabixAPI/app/services/api_key_service.py
Normal 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()
|
||||
|
||||
|
||||
161
hesabixAPI/app/services/auth_service.py
Normal file
161
hesabixAPI/app/services/auth_service.py
Normal 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)
|
||||
|
||||
|
||||
|
||||
97
hesabixAPI/app/services/captcha_service.py
Normal file
97
hesabixAPI/app/services/captcha_service.py
Normal 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
|
||||
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
BIN
hesabixAPI/locales/fa/LC_MESSAGES/messages.mo
Normal file
BIN
hesabixAPI/locales/fa/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
61
hesabixAPI/locales/fa/LC_MESSAGES/messages.po
Normal file
61
hesabixAPI/locales/fa/LC_MESSAGES/messages.po
Normal 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 "توکن بازنشانی نامعتبر یا منقضی شده است."
|
||||
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}');
|
||||
|
|
|
|||
52
hesabixUI/hesabix_ui/lib/core/auth_store.dart
Normal file
52
hesabixUI/hesabix_ui/lib/core/auth_store.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "کد امنیتی الزامی است."
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,4 +83,13 @@ class AppLocalizationsFa extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get brandTagline => 'مدیریت مالی هرجا و هر زمان با اطمینان.';
|
||||
|
||||
@override
|
||||
String get captcha => 'کد امنیتی';
|
||||
|
||||
@override
|
||||
String get refresh => 'تازهسازی';
|
||||
|
||||
@override
|
||||
String get captchaRequired => 'کد امنیتی الزامی است.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
hesabixUI/hesabix_ui/lib/widgets/error_notice.dart
Normal file
51
hesabixUI/hesabix_ui/lib/widgets/error_notice.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
Loading…
Reference in a new issue