Implement email verification flow: add EmailVerificationToken model, update user registration and login status handling, and create verification endpoints
This commit is contained in:
parent
0355781852
commit
14c35d2bde
40
app/dependencies.py
Normal file
40
app/dependencies.py
Normal file
@ -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
|
||||||
@ -1,6 +1,7 @@
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.user_profile import UserProfile
|
from app.models.user_profile import UserProfile
|
||||||
from app.models.password_reset_token import PasswordResetToken
|
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.business_type import BusinessType
|
||||||
from app.models.plan import Plan
|
from app.models.plan import Plan
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
@ -10,6 +11,7 @@ __all__ = [
|
|||||||
"User",
|
"User",
|
||||||
"UserProfile",
|
"UserProfile",
|
||||||
"PasswordResetToken",
|
"PasswordResetToken",
|
||||||
|
"EmailVerificationToken",
|
||||||
"BusinessType",
|
"BusinessType",
|
||||||
"Plan",
|
"Plan",
|
||||||
"Tenant",
|
"Tenant",
|
||||||
|
|||||||
21
app/models/email_verification_token.py
Normal file
21
app/models/email_verification_token.py
Normal file
@ -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")
|
||||||
@ -17,10 +17,11 @@ class User(Base):
|
|||||||
password: Mapped[str] = mapped_column(String(255))
|
password: Mapped[str] = mapped_column(String(255))
|
||||||
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
tenant_id: Mapped[str | None] = mapped_column(CHAR(36), 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, onupdate=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)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False)
|
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False)
|
||||||
password_reset_tokens: Mapped[list["PasswordResetToken"]] = relationship(back_populates="user")
|
password_reset_tokens: Mapped[list["PasswordResetToken"]] = relationship(back_populates="user")
|
||||||
|
email_verification_tokens: Mapped[list["EmailVerificationToken"]] = relationship(back_populates="user")
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import random
|
import random
|
||||||
|
import secrets
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from google.oauth2 import id_token
|
from google.oauth2 import id_token
|
||||||
@ -9,22 +10,35 @@ from sqlalchemy.orm import Session, selectinload
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import get_db
|
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 import User
|
||||||
from app.models.user_profile import UserProfile
|
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 (
|
from app.schemas.user import (
|
||||||
GoogleLoginRequest,
|
GoogleLoginRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
|
UserDetailResponse,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
from app.security import create_access_token, hash_password, verify_password
|
from app.security import create_access_token, hash_password, verify_password
|
||||||
from app.services.auth_service import forgot_password, reset_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"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_verification_token() -> str:
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
def login(data: LoginRequest, db: Session = Depends(get_db)):
|
def login(data: LoginRequest, db: Session = Depends(get_db)):
|
||||||
user = db.query(User).options(selectinload(User.profile)).filter(
|
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,
|
email=data.email,
|
||||||
username=data.username,
|
username=data.username,
|
||||||
password=hash_password(data.password),
|
password=hash_password(data.password),
|
||||||
|
status="INACTIVE",
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.flush()
|
db.flush()
|
||||||
@ -85,11 +100,26 @@ def register(data: UserCreate, db: Session = Depends(get_db)):
|
|||||||
profile_data["dob"] = data.dob
|
profile_data["dob"] = data.dob
|
||||||
profile = UserProfile(**profile_data)
|
profile = UserProfile(**profile_data)
|
||||||
db.add(profile)
|
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()
|
db.commit()
|
||||||
except:
|
except:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise
|
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)
|
db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@ -110,7 +140,6 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
email = idinfo.get("email")
|
email = idinfo.get("email")
|
||||||
name = idinfo.get("name", "")
|
name = idinfo.get("name", "")
|
||||||
picture = idinfo.get("picture", "")
|
|
||||||
|
|
||||||
if not email:
|
if not email:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -138,7 +167,7 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
|
|||||||
username=username,
|
username=username,
|
||||||
password=hash_password(random_password),
|
password=hash_password(random_password),
|
||||||
email_verified_at=datetime.now(timezone.utc),
|
email_verified_at=datetime.now(timezone.utc),
|
||||||
status="ACTIVE",
|
status="ONBOARDING",
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.flush()
|
db.flush()
|
||||||
@ -162,6 +191,8 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
|
|||||||
elif user.profile.full_name != name:
|
elif user.profile.full_name != name:
|
||||||
user.profile.full_name = name
|
user.profile.full_name = name
|
||||||
db.commit()
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
access_token = create_access_token({"sub": user.id})
|
access_token = create_access_token({"sub": user.id})
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
access_token=access_token,
|
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")
|
@router.post("/forgot-password")
|
||||||
def forgot_password_endpoint(data: ForgotPasswordRequest, db: Session = Depends(get_db)):
|
def forgot_password_endpoint(data: ForgotPasswordRequest, db: Session = Depends(get_db)):
|
||||||
return forgot_password(db, data.email)
|
return forgot_password(db, data.email)
|
||||||
|
|||||||
@ -2,6 +2,8 @@ from fastapi import APIRouter, Depends, Request, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
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.responses import created_response
|
||||||
from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate
|
from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate
|
||||||
from app.services.tenant_service import (
|
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)
|
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||||
def create_tenant_route(
|
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)
|
tenant = create_tenant(db, data)
|
||||||
|
assign_user_to_tenant(db, tenant.id, current_user.id)
|
||||||
location = f"{request.url.path}{tenant.id}"
|
location = f"{request.url.path}{tenant.id}"
|
||||||
return created_response(location)
|
return created_response(location)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
from pydantic import BaseModel, EmailStr, field_validator
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyEmailRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class SendVerificationRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
class ForgotPasswordRequest(BaseModel):
|
class ForgotPasswordRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ class UserCreate(BaseModel):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
status: str = "ONBOARDING"
|
|
||||||
full_name: str
|
full_name: str
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
address: str | None = None
|
address: str | None = None
|
||||||
|
|||||||
@ -3,6 +3,58 @@ import resend
|
|||||||
from app.config import settings
|
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"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body style="font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #fafafa; margin: 0; padding: 40px 20px;">
|
||||||
|
<div style="max-width: 480px; margin: 0 auto; background: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);">
|
||||||
|
<div style="text-align: center; margin: 0 0 24px;">
|
||||||
|
<div style="display: inline-block; width: 48px; height: 48px; background-color: #f0fdf4; border-radius: 12px; line-height: 48px;">
|
||||||
|
<span style="font-size: 24px;">✉️</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 style="font-size: 20px; font-weight: 600; color: #1a1a1a; margin: 0 0 8px; text-align: center;">Verifikasi Email</h1>
|
||||||
|
<p style="font-size: 14px; color: #71717a; margin: 0 0 24px; line-height: 1.6; text-align: center;">
|
||||||
|
Klik tombol di bawah untuk memverifikasi alamat email Anda dan mulai menggunakan Profitra.
|
||||||
|
</p>
|
||||||
|
<div style="text-align: center; margin: 0 0 24px;">
|
||||||
|
<a href="{verify_url}"
|
||||||
|
style="display: inline-block; background-color: #22C55E; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 30px; font-size: 15px; font-weight: 500;">
|
||||||
|
Verifikasi Email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 13px; color: #a1a1aa; margin: 0 0 8px; line-height: 1.6; text-align: center;">
|
||||||
|
Link ini berlaku selama <strong>24 jam</strong>.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #a1a1aa; margin: 0; line-height: 1.6; text-align: center;">
|
||||||
|
Jika Anda tidak mendaftar akun Profitra, abaikan email ini.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 12px; color: #d4d4d8; text-align: center; margin: 24px 0 0;">
|
||||||
|
© 2026 Profitra. Semua hak dilindungi.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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:
|
def send_password_reset_email(to_email: str, reset_url: str) -> None:
|
||||||
resend.api_key = settings.RESEND_API_KEY
|
resend.api_key = settings.RESEND_API_KEY
|
||||||
|
|
||||||
|
|||||||
71
docs/history/2026-07-25-user-status-flow.md
Normal file
71
docs/history/2026-07-25-user-status-flow.md
Normal file
@ -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)
|
||||||
Loading…
Reference in New Issue
Block a user