diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..999c809 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,40 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.user import User +from app.security import decode_access_token + +security = HTTPBearer() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +) -> User: + payload = decode_access_token(credentials.credentials) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"message": "Token tidak valid."}, + ) + + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"message": "Token tidak valid."}, + ) + + user = db.query(User).filter( + User.id == user_id, + User.deleted_at.is_(None), + ).first() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"message": "Pengguna tidak ditemukan."}, + ) + + return user diff --git a/app/models/__init__.py b/app/models/__init__.py index a8ac254..6c4f999 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,7 @@ from app.models.user import User from app.models.user_profile import UserProfile from app.models.password_reset_token import PasswordResetToken +from app.models.email_verification_token import EmailVerificationToken from app.models.business_type import BusinessType from app.models.plan import Plan from app.models.tenant import Tenant @@ -10,6 +11,7 @@ __all__ = [ "User", "UserProfile", "PasswordResetToken", + "EmailVerificationToken", "BusinessType", "Plan", "Tenant", diff --git a/app/models/email_verification_token.py b/app/models/email_verification_token.py new file mode 100644 index 0000000..e9745b4 --- /dev/null +++ b/app/models/email_verification_token.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.dialects.mysql import CHAR +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class EmailVerificationToken(Base): + __tablename__ = "email_verification_tokens" + + id: Mapped[str] = mapped_column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column(CHAR(36), ForeignKey("users.id"), index=True) + token: Mapped[str] = mapped_column(String(255), unique=True, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime) + used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + user: Mapped["User"] = relationship(back_populates="email_verification_tokens") diff --git a/app/models/user.py b/app/models/user.py index 0e17ff4..e4cd597 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -17,10 +17,11 @@ class User(Base): password: Mapped[str] = mapped_column(String(255)) email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) tenant_id: Mapped[str | None] = mapped_column(CHAR(36), nullable=True) - status: Mapped[str] = mapped_column(String(20), default="ONBOARDING") + status: Mapped[str] = mapped_column(String(20), default="INACTIVE") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, onupdate=datetime.now) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False) password_reset_tokens: Mapped[list["PasswordResetToken"]] = relationship(back_populates="user") + email_verification_tokens: Mapped[list["EmailVerificationToken"]] = relationship(back_populates="user") diff --git a/app/routers/auth.py b/app/routers/auth.py index 7a1529b..c2b1559 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -1,6 +1,7 @@ import random +import secrets import string -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from google.oauth2 import id_token @@ -9,22 +10,35 @@ from sqlalchemy.orm import Session, selectinload from app.config import settings from app.database import get_db +from app.dependencies import get_current_user +from app.models.email_verification_token import EmailVerificationToken from app.models.user import User from app.models.user_profile import UserProfile -from app.schemas.auth import ForgotPasswordRequest, ResetPasswordRequest +from app.schemas.auth import ( + ForgotPasswordRequest, + ResetPasswordRequest, + SendVerificationRequest, + VerifyEmailRequest, +) from app.schemas.user import ( GoogleLoginRequest, LoginRequest, LoginResponse, UserCreate, + UserDetailResponse, UserResponse, ) from app.security import create_access_token, hash_password, verify_password from app.services.auth_service import forgot_password, reset_password +from app.services.email_service import send_verification_email router = APIRouter(prefix="/auth", tags=["auth"]) +def _generate_verification_token() -> str: + return secrets.token_urlsafe(48) + + @router.post("/login", response_model=LoginResponse) def login(data: LoginRequest, db: Session = Depends(get_db)): user = db.query(User).options(selectinload(User.profile)).filter( @@ -66,6 +80,7 @@ def register(data: UserCreate, db: Session = Depends(get_db)): email=data.email, username=data.username, password=hash_password(data.password), + status="INACTIVE", ) db.add(user) db.flush() @@ -85,11 +100,26 @@ def register(data: UserCreate, db: Session = Depends(get_db)): profile_data["dob"] = data.dob profile = UserProfile(**profile_data) db.add(profile) + + raw_token = _generate_verification_token() + hashed_token = hash_password(raw_token) + verif_token = EmailVerificationToken( + user_id=user.id, + token=hashed_token, + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + db.add(verif_token) db.commit() except: db.rollback() raise + verify_url = f"{settings.FRONTEND_URL}/auth/verify-email?token={raw_token}" + try: + send_verification_email(user.email, verify_url) + except Exception: + pass + db.refresh(user) return user @@ -110,7 +140,6 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)): email = idinfo.get("email") name = idinfo.get("name", "") - picture = idinfo.get("picture", "") if not email: raise HTTPException( @@ -138,7 +167,7 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)): username=username, password=hash_password(random_password), email_verified_at=datetime.now(timezone.utc), - status="ACTIVE", + status="ONBOARDING", ) db.add(user) db.flush() @@ -162,6 +191,8 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)): elif user.profile.full_name != name: user.profile.full_name = name db.commit() + db.refresh(user) + access_token = create_access_token({"sub": user.id}) return LoginResponse( access_token=access_token, @@ -169,6 +200,119 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)): ) +@router.post("/verify-email") +def verify_email(data: VerifyEmailRequest, db: Session = Depends(get_db)): + now = datetime.now(timezone.utc) + + tokens = db.query(EmailVerificationToken).filter( + EmailVerificationToken.used_at.is_(None), + ).all() + + matched = None + for t in tokens: + if verify_password(data.token, t.token): + matched = t + break + + if not matched: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"message": "Token tidak valid atau sudah kedaluwarsa."}, + ) + + if matched.expires_at.replace(tzinfo=timezone.utc) < now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"message": "Token tidak valid atau sudah kedaluwarsa."}, + ) + + matched.used_at = now + user = db.query(User).filter(User.id == matched.user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"message": "Token tidak valid atau sudah kedaluwarsa."}, + ) + + user.email_verified_at = now + user.status = "ONBOARDING" + + db.query(EmailVerificationToken).filter( + EmailVerificationToken.user_id == user.id, + EmailVerificationToken.used_at.is_(None), + ).update({"used_at": now}) + + db.commit() + + return {"message": "Email berhasil diverifikasi. Silakan masuk ke akun Anda."} + + +@router.post("/send-verification") +def send_verification(data: SendVerificationRequest, db: Session = Depends(get_db)): + user = db.query(User).filter( + User.email == data.email, + User.deleted_at.is_(None), + ).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"message": "Email tidak ditemukan."}, + ) + + if user.email_verified_at is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"message": "Email sudah diverifikasi."}, + ) + + now = datetime.now(timezone.utc) + + active_token = db.query(EmailVerificationToken).filter( + EmailVerificationToken.user_id == user.id, + EmailVerificationToken.used_at.is_(None), + EmailVerificationToken.expires_at > now, + ).first() + + if active_token: + return {"message": "Link verifikasi sudah dikirim ke email Anda dan masih aktif."} + + db.query(EmailVerificationToken).filter( + EmailVerificationToken.user_id == user.id, + EmailVerificationToken.used_at.is_(None), + ).update({"used_at": now}) + + raw_token = _generate_verification_token() + hashed_token = hash_password(raw_token) + + verif_token = EmailVerificationToken( + user_id=user.id, + token=hashed_token, + expires_at=now + timedelta(hours=24), + ) + db.add(verif_token) + db.commit() + + verify_url = f"{settings.FRONTEND_URL}/auth/verify-email?token={raw_token}" + try: + send_verification_email(user.email, verify_url) + except Exception: + pass + + return {"message": "Link verifikasi telah dikirim ke email Anda."} + + +@router.get("/me", response_model=UserDetailResponse) +def get_me( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + user = db.query(User).options(selectinload(User.profile)).filter( + User.id == current_user.id + ).first() + return user + + @router.post("/forgot-password") def forgot_password_endpoint(data: ForgotPasswordRequest, db: Session = Depends(get_db)): return forgot_password(db, data.email) diff --git a/app/routers/tenants.py b/app/routers/tenants.py index 5940055..fb28659 100644 --- a/app/routers/tenants.py +++ b/app/routers/tenants.py @@ -2,6 +2,8 @@ from fastapi import APIRouter, Depends, Request, status from sqlalchemy.orm import Session from app.database import get_db +from app.dependencies import get_current_user +from app.models.user import User from app.responses import created_response from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate from app.services.tenant_service import ( @@ -28,9 +30,13 @@ def get_tenant(id: str, db: Session = Depends(get_db)): @router.post("/", status_code=status.HTTP_201_CREATED) def create_tenant_route( - request: Request, data: TenantCreate, db: Session = Depends(get_db) + request: Request, + data: TenantCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): tenant = create_tenant(db, data) + assign_user_to_tenant(db, tenant.id, current_user.id) location = f"{request.url.path}{tenant.id}" return created_response(location) diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 399ffc1..c7e4655 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -1,6 +1,14 @@ from pydantic import BaseModel, EmailStr, field_validator +class VerifyEmailRequest(BaseModel): + token: str + + +class SendVerificationRequest(BaseModel): + email: EmailStr + + class ForgotPasswordRequest(BaseModel): email: EmailStr diff --git a/app/schemas/user.py b/app/schemas/user.py index 7e0f299..d2791a3 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -7,7 +7,6 @@ class UserCreate(BaseModel): email: EmailStr username: str password: str - status: str = "ONBOARDING" full_name: str phone: str | None = None address: str | None = None diff --git a/app/services/email_service.py b/app/services/email_service.py index 322649e..24287dd 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -3,6 +3,58 @@ import resend from app.config import settings +def send_verification_email(to_email: str, verify_url: str) -> None: + resend.api_key = settings.RESEND_API_KEY + + html_content = f""" + + + + + + + + +
+
+
+ ✉️ +
+
+

Verifikasi Email

+

+ Klik tombol di bawah untuk memverifikasi alamat email Anda dan mulai menggunakan Profitra. +

+
+ + Verifikasi Email + +
+

+ Link ini berlaku selama 24 jam. +

+

+ Jika Anda tidak mendaftar akun Profitra, abaikan email ini. +

+
+

+ © 2026 Profitra. Semua hak dilindungi. +

+ + + """ + + params: resend.Emails.SendParams = { + "from": settings.EMAIL_FROM, + "to": [to_email], + "subject": "Verifikasi Email - Profitra", + "html": html_content, + } + + resend.Emails.send(params) + + def send_password_reset_email(to_email: str, reset_url: str) -> None: resend.api_key = settings.RESEND_API_KEY diff --git a/docs/history/2026-07-25-user-status-flow.md b/docs/history/2026-07-25-user-status-flow.md new file mode 100644 index 0000000..d73e0f8 --- /dev/null +++ b/docs/history/2026-07-25-user-status-flow.md @@ -0,0 +1,71 @@ +# Perubahan Flow Status User + +**Tanggal:** 2026-07-25 +**Status:** Selesai + +## Tujuan +Menerapkan flow status user yang baru: +- Register via Google → status `ONBOARDING` +- Register manual → status `INACTIVE` → verifikasi email → status `ONBOARDING` +- Setelah isi onboarding → status `ACTIVE` + +## Yang Dikerjakan + +### 1. Ubah Default Status User di Model +- **Sebelum:** `default="ONBOARDING"` +- **Sesudah:** `default="INACTIVE"` + +### 2. Hapus Field Status dari UserCreate Schema +- Status tidak perlu dikirim dari frontend, diatur oleh backend + +### 3. Ubah Status di Register Endpoint +- `POST /auth/register`: Set status `INACTIVE` secara eksplisit +- Generate dan kirim verification token + email + +### 4. Ubah Status di Google Login Endpoint +- `POST /auth/google-login`: + - **Sebelum:** `status="ACTIVE"` + - **Sesudah:** `status="ONBOARDING"` (user baru) + - Existing user tidak diubah statusnya + +### 5. Buat EmailVerificationToken Model +- Model baru: `EmailVerificationToken` +- Relasi dengan User +- Expires 24 jam + +### 6. Tambah Fungsi Email Verifikasi +- `send_verification_email()` di `email_service.py` +- Template email verifikasi + +### 7. Tambah Endpoint Verifikasi Email +- `POST /auth/verify-email` — verifikasi token, set `email_verified_at` dan `status=ONBOARDING` +- `POST /auth/send-verification` — kirim ulang link verifikasi +- `GET /auth/me` — get current user (dengan profile) + +### 8. Auth Dependency +- `dependencies.py` — `get_current_user()` untuk proteksi endpoint + +### 9. Auto-Assign User saat Create Tenant +- `POST /tenants` sekarang require auth +- Setelah create tenant, auto-assign user ke tenant dan set status `ACTIVE` + +## File yang Diubah + +| File | Aksi | Detail | +|------|------|--------| +| `app/models/user.py` | Diubah | Default status `ONBOARDING` → `INACTIVE` | +| `app/models/email_verification_token.py` | Dibuat | Model token verifikasi email | +| `app/models/__init__.py` | Diubah | Export EmailVerificationToken | +| `app/schemas/user.py` | Diubah | Hapus status dari UserCreate | +| `app/schemas/auth.py` | Diubah | Tambah VerifyEmailRequest, SendVerificationRequest | +| `app/services/email_service.py` | Diubah | Tambah send_verification_email() | +| `app/dependencies.py` | Dibuat | Auth dependency get_current_user | +| `app/routers/auth.py` | Diubah | Status INACTIVE di register, ONBOARDING di google, tambah verify/send-verification/me | +| `app/routers/tenants.py` | Diubah | Auth dependency di create tenant, auto-assign user | +| `app/services/tenant_service.py` | Tidak diubah | (assign_user_to_tenant sudah ada) | + +## Notes +- Admin seeder tetap menggunakan ACTIVE untuk admin user +- UserFactory tetap menggunakan ACTIVE untuk test data +- Email verifikasi dikirim via Resend API (sama dengan forgot password) +- Jika gagal kirim email, register tetap berhasil (error email diabaikan dengan try/except)