diff --git a/.env.example b/.env.example index 162971a..a20a317 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,13 @@ JWT_ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=15 REFRESH_TOKEN_EXPIRE_DAYS=7 +# ============================================================================= +# Admin Bootstrap (optional) +# ============================================================================= +# If set, registering with this email auto-promotes to admin role. +# Leave empty or remove to disable auto-admin (all users register as USER). +ADMIN_EMAIL= + # ============================================================================= # CORS (must match HOST PORTS above!) # ============================================================================= diff --git a/backend/alembic/versions/20251224_033104_initial.py b/backend/alembic/versions/20251224_033104_initial.py new file mode 100644 index 0000000..acf8814 --- /dev/null +++ b/backend/alembic/versions/20251224_033104_initial.py @@ -0,0 +1,67 @@ +"""initial + +Revision ID: 801b86be184b +Revises: +Create Date: 2025-12-24 03:31:04.712921 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '801b86be184b' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('email', sa.String(length=320), nullable=False), + sa.Column('hashed_password', sa.String(length=1024), nullable=False), + sa.Column('full_name', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_verified', sa.Boolean(), nullable=False), + sa.Column('role', sa.Enum('unknown', 'user', 'admin', name='userrole'), nullable=False), + sa.Column('token_version', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_users')) + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('refresh_tokens', + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('family_id', sa.Uuid(), nullable=False), + sa.Column('device_id', sa.String(length=255), nullable=True), + sa.Column('device_name', sa.String(length=100), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_revoked', sa.Boolean(), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_refresh_tokens_user_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_tokens')) + ) + op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False) + op.create_index(op.f('ix_refresh_tokens_family_id'), 'refresh_tokens', ['family_id'], unique=False) + op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=True) + op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_family_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens') + op.drop_table('refresh_tokens') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/2025_12_07_001_initial_schema.py b/backend/alembic/versions/2025_12_07_001_initial_schema.py deleted file mode 100644 index 1138d38..0000000 --- a/backend/alembic/versions/2025_12_07_001_initial_schema.py +++ /dev/null @@ -1,28 +0,0 @@ -"""initial_schema - -Revision ID: ee8bde7caa8b -Revises: -Create Date: 2025-12-07 21:27:04.856781 -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = 'ee8bde7caa8b' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index 6dcb5de..0000000 --- a/backend/app/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -ⒸAngelaMos | 2025 -__init__.py - -⠀⠀⠀⠀⠀⠴⣦⣤⡀⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⣨⣥⣄⣀⠀⡁⠀⠀⡀⡠⠀⠀⠀⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⢠⣾⣿⣷⣮⣷⡦⠥⠈⡶⠮⣤⣀⡠⠀⡀⣐⣀⡈⠁⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⠟⠀⠠⠊⠉⠀⠀⢀⠉⠙⠚⠧⣦⣀⡀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠠⠀⠁⠀⢤⠀⠀⠀⠨⡉⠛⠶⠤⣄⣄⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⡀⠀⠀⢰⠀⠍⡾⠆⠀⠀⣠⡦⠄⡀⠄⠀⠠⠀⠀⠀⠈⠙⠓⠦⢤⣀⡀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣶⣦⢠⡈⠀⠀⠀⠀⠀⠋⠛⠉⡂⠈⠙⠀⣰⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠺⠦⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠻⢿⣿⣿⣿⣿⣿⣾⣿⣿⣦⢤⡀⢀⣂⣨⠀⢅⢱⡔⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠲⠴⣠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣎⠘⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣾⣽⡿⢿⣿⣿⣿⣿⣿⣿⣿⣿⠳⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠏⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠹⣦⣴⠖⠲⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠈⠀⠀⠀⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢩⠢⣙⠿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣆⠈⠛⢶⣌⡉⣻⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣷⣄⣤⣙⣿⣿⣿⣷⣄⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠟⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⣿⣿⣿⣿⣿⣿⡿⠋⠉⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠙⠁⠘⢮⣛⡽⠛⠿⡿⠥⠀ - -""" diff --git a/backend/app/__main__.py b/backend/app/__main__.py index 9fca783..fab2856 100644 --- a/backend/app/__main__.py +++ b/backend/app/__main__.py @@ -2,6 +2,7 @@ ⒸAngelaMos | 2025 __main__.py """ + import uvicorn from config import settings @@ -12,8 +13,30 @@ if __name__ == "__main__": uvicorn.run( - "__main__:app", + "app.__main__:app", host = settings.HOST, port = settings.PORT, reload = settings.RELOAD, ) +""" + +⠀⠀⠀⠀⠀⠴⣦⣤⡀⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⣨⣥⣄⣀⠀⡁⠀⠀⡀⡠⠀⠀⠀⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⢠⣾⣿⣷⣮⣷⡦⠥⠈⡶⠮⣤⣀⡠⠀⡀⣐⣀⡈⠁⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⠟⠀⠠⠊⠉⠀⠀⢀⠉⠙⠚⠧⣦⣀⡀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠠⠀⠁⠀⢤⠀⠀⠀⠨⡉⠛⠶⠤⣄⣄⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⡀⠀⠀⢰⠀⠍⡾⠆⠀⠀⣠⡦⠄⡀⠄⠀⠠⠀⠀⠀⠈⠙⠓⠦⢤⣀⡀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣶⣦⢠⡈⠀⠀⠀⠀⠀⠋⠛⠉⡂⠈⠙⠀⣰⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠺⠦⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠻⢿⣿⣿⣿⣿⣿⣾⣿⣿⣦⢤⡀⢀⣂⣨⠀⢅⢱⡔⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠲⠴⣠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣎⠘⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣾⣽⡿⢿⣿⣿⣿⣿⣿⣿⣿⣿⠳⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠏⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠹⣦⣴⠖⠲⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠈⠀⠀⠀⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢩⠢⣙⠿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣆⠈⠛⢶⣌⡉⣻⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣷⣄⣤⣙⣿⣿⣿⣷⣄⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠟⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⣿⣿⣿⣿⣿⣿⡿⠋⠉⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠙⠁⠘⢮⣛⡽⠛⠿⡿⠥⠀ + +""" diff --git a/backend/app/admin/__init__.py b/backend/app/admin/__init__.py index e69de29..fa3dc88 100644 --- a/backend/app/admin/__init__.py +++ b/backend/app/admin/__init__.py @@ -0,0 +1,4 @@ +""" +ⒸAngelaMos | 2025 +Admin Domain +""" diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py index e69de29..e2ef245 100644 --- a/backend/app/auth/__init__.py +++ b/backend/app/auth/__init__.py @@ -0,0 +1,4 @@ +""" +ⒸAngelaMos | 2025 +Auth Domain +""" diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index d6cee83..afbb10e 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -74,6 +74,7 @@ async def login( responses = {**AUTH_401} ) async def refresh_token( + response: Response, auth_service: AuthServiceDep, ip: ClientIP, refresh_token: str | None = Cookie(None), @@ -83,10 +84,12 @@ async def refresh_token( """ if not refresh_token: raise TokenError("Refresh token required") - return await auth_service.refresh_tokens( + result, new_refresh_token = await auth_service.refresh_tokens( refresh_token, ip_address = ip ) + set_refresh_cookie(response, new_refresh_token) + return result @router.post( diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 0db8ea3..f0557a1 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -63,7 +63,11 @@ async def authenticate( raise InvalidCredentials() if new_hash: - await UserRepository.update_password(self.session, user, new_hash) + await UserRepository.update_password( + self.session, + user, + new_hash + ) access_token = create_access_token(user.id, user.token_version) @@ -115,7 +119,7 @@ async def refresh_tokens( device_id: str | None = None, device_name: str | None = None, ip_address: str | None = None, - ) -> TokenResponse: + ) -> tuple[TokenResponse, str]: """ Refresh access token using refresh token @@ -147,11 +151,14 @@ async def refresh_tokens( if user is None or not user.is_active: raise TokenError(message = "User not found or inactive") - await RefreshTokenRepository.revoke_token(self.session, stored_token) + await RefreshTokenRepository.revoke_token( + self.session, + stored_token + ) access_token = create_access_token(user.id, user.token_version) - _, new_hash, expires_at = create_refresh_token( + new_raw_token, new_hash, expires_at = create_refresh_token( user.id, stored_token.family_id ) @@ -166,7 +173,7 @@ async def refresh_tokens( ip_address = ip_address, ) - return TokenResponse(access_token = access_token) + return TokenResponse(access_token = access_token), new_raw_token async def logout( self, diff --git a/backend/app/config.py b/backend/app/config.py index b54b6c2..7ed8739 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,6 +8,7 @@ from functools import lru_cache from pydantic import ( + EmailStr, Field, RedisDsn, SecretStr, @@ -80,9 +81,9 @@ class Settings(BaseSettings): APP_NAME: str = "FastAPI Template" APP_VERSION: str = "1.0.0" - APP_SUMMARY: str = "FastAPI Backend Template" - APP_DESCRIPTION: str = "Async first boilerplate - JWT, Asyncdb, PostgreSQL" - APP_CONTACT_NAME: str = "AngelaMos" + APP_SUMMARY: str = "Developed CarterPerez-dev" + APP_DESCRIPTION: str = "FastAPI async first boilerplate - JWT, Asyncdb, PostgreSQL" + APP_CONTACT_NAME: str = "AngelaMos LLC" APP_CONTACT_EMAIL: str = "support@certgames.com" APP_LICENSE_NAME: str = "MIT" APP_LICENSE_URL: str = "https://github.com/CarterPerez-dev/fullstack-template/docs/templates/MIT" @@ -105,6 +106,8 @@ class Settings(BaseSettings): ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default = 15, ge = 5, le = 60) REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default = 7, ge = 1, le = 30) + ADMIN_EMAIL: EmailStr | None = None + REDIS_URL: RedisDsn | None = None CORS_ORIGINS: list[str] = [ diff --git a/backend/app/factory.py b/backend/app/factory.py index 4a8066b..52903d9 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -80,7 +80,9 @@ def create_app() -> FastAPI: "url": settings.APP_LICENSE_URL, }, openapi_tags = OPENAPI_TAGS, + openapi_version = "3.1.0", lifespan = lifespan, + root_path = "/api" if not is_production else "", openapi_url = None if is_production else "/openapi.json", docs_url = None if is_production else "/docs", redoc_url = None if is_production else "/redoc", diff --git a/backend/app/user/__init__.py b/backend/app/user/__init__.py index e69de29..10f02dc 100644 --- a/backend/app/user/__init__.py +++ b/backend/app/user/__init__.py @@ -0,0 +1,4 @@ +""" +ⒸAngelaMos | 2025 +User Domain +""" diff --git a/backend/app/user/repository.py b/backend/app/user/repository.py index e1adfc9..1c0eba3 100644 --- a/backend/app/user/repository.py +++ b/backend/app/user/repository.py @@ -7,6 +7,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from config import UserRole from .User import User from core.base_repository import BaseRepository @@ -63,6 +64,7 @@ async def create_user( email: str, hashed_password: str, full_name: str | None = None, + role: UserRole = UserRole.USER, ) -> User: """ Create a new user @@ -71,6 +73,7 @@ async def create_user( email = email, hashed_password = hashed_password, full_name = full_name, + role = role, ) session.add(user) await session.flush() diff --git a/backend/app/user/service.py b/backend/app/user/service.py index e6e92f0..3a7d085 100644 --- a/backend/app/user/service.py +++ b/backend/app/user/service.py @@ -8,6 +8,7 @@ AsyncSession, ) +from config import settings, UserRole from core.exceptions import ( EmailAlreadyExists, InvalidCredentials, @@ -43,15 +44,22 @@ async def create_user( """ Register a new user """ - if await UserRepository.email_exists(self.session, user_data.email): + if await UserRepository.email_exists(self.session, + user_data.email): raise EmailAlreadyExists(user_data.email) + role = UserRole.USER + if settings.ADMIN_EMAIL and user_data.email.lower( + ) == settings.ADMIN_EMAIL.lower(): + role = UserRole.ADMIN + hashed = await hash_password(user_data.password) user = await UserRepository.create_user( self.session, email = user_data.email, hashed_password = hashed, full_name = user_data.full_name, + role = role, ) return UserResponse.model_validate(user) @@ -154,7 +162,8 @@ async def admin_create_user( """ Admin creates a new user """ - if await UserRepository.email_exists(self.session, user_data.email): + if await UserRepository.email_exists(self.session, + user_data.email): raise EmailAlreadyExists(user_data.email) hashed = await hash_password(user_data.password) diff --git a/backend/conftest.py b/backend/conftest.py index 3e66866..9b95ea0 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -5,6 +5,11 @@ Test configuration, fixtures, and factories """ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "app")) + import hashlib import secrets from datetime import ( @@ -83,7 +88,9 @@ async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]: """ Async HTTP client with DB session override """ - from __main__ import app + from factory import create_app + + app = create_app() async def override_get_db(): yield db_session diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 071a175..b62c78b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "full-stack-template" version = "1.0.0" -description = "Production grade FastAPI template with async SQLAlchemy and JWT auth" +description = "FastAPI template with async SQLAlchemy and JWT auth" requires-python = ">=3.12" dependencies = [ @@ -34,6 +34,9 @@ dev = [ "mypy>=1.19.0", "types-redis>=4.6.0", "ruff>=0.14.8", + "pylint>=4.0.4", + "pylint-pydantic>=0.4.1", + "pylint-per-file-ignores>=3.2.0", "ty>=0.0.1a32", "pre-commit>=4.2.0", ] @@ -105,7 +108,7 @@ warn_unused_ignores = true disallow_untyped_defs = true disallow_incomplete_defs = true plugins = ["pydantic.mypy"] -exclude = ["alembic"] +exclude = ["alembic", ".venv", "venv"] [[tool.mypy.overrides]] module = ["tests.*", "conftest"] @@ -164,7 +167,6 @@ load-plugins = [ "pylint_per_file_ignores", ] persistent = true -suggestion-mode = true ignore = [ "alembic", "venv", @@ -216,6 +218,7 @@ disable = [ [tool.pylint-per-file-ignores] "alembic/env.py" = "no-member" "conftest.py" = "import-outside-toplevel" +"app/__main__.py" = "pointless-string-statement" [tool.pylint.format] max-line-length = 95 @@ -250,13 +253,13 @@ exclude_lines = [ [tool.ty.src] -include = ["src", "tests"] +include = ["app", "tests"] exclude = ["alembic/versions/**", ".venv/**"] respect-ignore-files = true [tool.ty.environment] python-version = "3.12" -root = ["./src"] +root = ["./app"] python = "./.venv" [tool.ty.rules] @@ -273,7 +276,7 @@ unresolved-reference = "warn" invalid-argument-type = "warn" [[tool.ty.overrides]] -include = ["src/repositories/**", "src/services/**"] +include = ["app/repositories/**", "app/services/**"] [tool.ty.overrides.rules] unresolved-attribute = "warn" diff --git a/backend/tests/integration/test_admin.py b/backend/tests/integration/test_admin.py new file mode 100644 index 0000000..6c97f62 --- /dev/null +++ b/backend/tests/integration/test_admin.py @@ -0,0 +1,256 @@ +""" +©AngelaMos | 2025 +test_admin.py +""" + +import pytest +from httpx import AsyncClient + +from user.User import User + + +URL_ADMIN_USERS = "/v1/admin/users" + + +def url_admin_user_by_id(user_id: str) -> str: + return f"{URL_ADMIN_USERS}/{user_id}" + + +@pytest.mark.asyncio +async def test_admin_create_user( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], +): + """ + Admin can create a new user + """ + response = await client.post( + URL_ADMIN_USERS, + headers = admin_auth_headers, + json = { + "email": "adminmade@test.com", + "password": "ValidPass123", + "full_name": "Admin Created User", + }, + ) + + assert response.status_code == 201 + data = response.json() + assert data["email"] == "adminmade@test.com" + assert data["full_name"] == "Admin Created User" + assert "id" in data + assert "hashed_password" not in data + + +@pytest.mark.asyncio +async def test_admin_create_user_non_admin_forbidden( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, str], +): + """ + Non admin cannot create user via admin endpoint + """ + response = await client.post( + URL_ADMIN_USERS, + headers = auth_headers, + json = { + "email": "shouldfail@test.com", + "password": "ValidPass123", + }, + ) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_create_user_duplicate_email( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], + test_user: User, +): + """ + Admin create with duplicate email returns 409 + """ + response = await client.post( + URL_ADMIN_USERS, + headers = admin_auth_headers, + json = { + "email": test_user.email, + "password": "ValidPass123", + }, + ) + + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_admin_get_user_by_id( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], + test_user: User, +): + """ + Admin can get any user by ID + """ + response = await client.get( + url_admin_user_by_id(str(test_user.id)), + headers = admin_auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(test_user.id) + assert data["email"] == test_user.email + + +@pytest.mark.asyncio +async def test_admin_get_user_not_found( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], +): + """ + Admin get non existent user returns 404 + """ + fake_id = "00000000-0000-0000-0000-000000000000" + response = await client.get( + url_admin_user_by_id(fake_id), + headers = admin_auth_headers, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_update_user( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], + test_user: User, +): + """ + Admin can update any user + """ + response = await client.patch( + url_admin_user_by_id(str(test_user.id)), + headers = admin_auth_headers, + json = {"full_name": "Admin Updated Name"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["full_name"] == "Admin Updated Name" + assert data["id"] == str(test_user.id) + + +@pytest.mark.asyncio +async def test_admin_update_user_not_found( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], +): + """ + Admin update non existent user returns 404 + """ + fake_id = "00000000-0000-0000-0000-000000000000" + response = await client.patch( + url_admin_user_by_id(fake_id), + headers = admin_auth_headers, + json = {"full_name": "Should Fail"}, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_update_user_non_admin_forbidden( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, str], +): + """ + Non admin cannot update via admin endpoint + """ + response = await client.patch( + url_admin_user_by_id(str(test_user.id)), + headers = auth_headers, + json = {"full_name": "Should Fail"}, + ) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_delete_user( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], + db_session, +): + """ + Admin can delete a user + """ + from user.User import User as UserModel + from core.security import hash_password + + user_to_delete = UserModel( + email = "deleteme@test.com", + hashed_password = await hash_password("TestPass123"), + full_name = "Delete Me", + ) + db_session.add(user_to_delete) + await db_session.flush() + await db_session.refresh(user_to_delete) + user_id = str(user_to_delete.id) + + response = await client.delete( + url_admin_user_by_id(user_id), + headers = admin_auth_headers, + ) + + assert response.status_code == 204 + + get_response = await client.get( + url_admin_user_by_id(user_id), + headers = admin_auth_headers, + ) + assert get_response.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_delete_user_not_found( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], +): + """ + Admin delete non existent user returns 404 + """ + fake_id = "00000000-0000-0000-0000-000000000000" + response = await client.delete( + url_admin_user_by_id(fake_id), + headers = admin_auth_headers, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_delete_user_non_admin_forbidden( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, str], +): + """ + Non admin cannot delete users + """ + response = await client.delete( + url_admin_user_by_id(str(test_user.id)), + headers = auth_headers, + ) + + assert response.status_code == 403 diff --git a/backend/tests/integration/test_users.py b/backend/tests/integration/test_users.py index 48f845d..46a0800 100644 --- a/backend/tests/integration/test_users.py +++ b/backend/tests/integration/test_users.py @@ -9,7 +9,8 @@ from user.User import User -URL_USERS = "/v1/admin/users" +URL_USERS = "/v1/users" +URL_ADMIN_USERS = "/v1/admin/users" URL_USER_ME = "/v1/users/me" @@ -17,6 +18,10 @@ def url_user_by_id(user_id: str) -> str: return f"{URL_USERS}/{user_id}" +def url_admin_user_by_id(user_id: str) -> str: + return f"{URL_ADMIN_USERS}/{user_id}" + + @pytest.mark.asyncio async def test_create_user(client: AsyncClient): """ @@ -179,7 +184,7 @@ async def test_list_users_admin_only( Non admin cannot list users (403). """ response = await client.get( - URL_USERS, + URL_ADMIN_USERS, headers = auth_headers, ) @@ -197,7 +202,7 @@ async def test_list_users_as_admin( Admin can list users with pagination """ response = await client.get( - URL_USERS, + URL_ADMIN_USERS, headers = admin_auth_headers, ) @@ -221,7 +226,7 @@ async def test_list_users_pagination( Pagination params work correctly """ response = await client.get( - URL_USERS, + URL_ADMIN_USERS, headers = admin_auth_headers, params = { "page": 1, diff --git a/backend/uv.lock b/backend/uv.lock index bfa5643..21cb83e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -118,6 +118,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" }, ] +[[package]] +name = "astroid" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/22/97df040e15d964e592d3a180598ace67e91b7c559d8298bdb3c949dc6e42/astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070", size = 405714, upload-time = "2025-11-09T21:21:18.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ac/a85b4bfb4cf53221513e27f33cc37ad158fce02ac291d18bee6b49ab477d/astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b", size = 276354, upload-time = "2025-11-09T21:21:16.54Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -396,6 +405,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -598,6 +616,9 @@ dev = [ { name = "httpx" }, { name = "mypy" }, { name = "pre-commit" }, + { name = "pylint" }, + { name = "pylint-per-file-ignores" }, + { name = "pylint-pydantic" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -622,6 +643,9 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" }, { name = "pyjwt", specifier = ">=2.10.0" }, + { name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" }, + { name = "pylint-per-file-ignores", marker = "extra == 'dev'", specifier = ">=3.2.0" }, + { name = "pylint-pydantic", marker = "extra == 'dev'", specifier = ">=0.4.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, @@ -782,6 +806,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -947,6 +980,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1246,6 +1288,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] +[[package]] +name = "pylint" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" }, +] + +[[package]] +name = "pylint-per-file-ignores" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pylint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/b2/cf916c3c8127282f60927a3fd382ef8e477e6ef090b3d1f1fedd62bff916/pylint_per_file_ignores-3.2.0.tar.gz", hash = "sha256:5eb30b2b64c49ca616b8940346b8b5b4973eeaa15700840c8b81a4b8ba565a02", size = 63854, upload-time = "2025-11-25T14:13:14.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/6a/09cbda0032e1040eea8c7daec3994e6d32b3edc26a81d226fd643537886b/pylint_per_file_ignores-3.2.0-py3-none-any.whl", hash = "sha256:8b995b7486f6652f942cf5721e24c29b72735fa911b6d22b65b2f87bad323590", size = 5576, upload-time = "2025-11-25T14:13:13.208Z" }, +] + +[[package]] +name = "pylint-plugin-utils" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pylint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/85/24eaf5d0d078fc8799ae6d89faf326d6e4d27d862fc9a710a52ab07b7bb5/pylint_plugin_utils-0.9.0.tar.gz", hash = "sha256:5468d763878a18d5cc4db46eaffdda14313b043c962a263a7d78151b90132055", size = 10474, upload-time = "2025-06-24T07:14:00.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/c9/a3b871b0b590c49e38884af6dab58ab9711053bd5c39b8899b72e367b9f6/pylint_plugin_utils-0.9.0-py3-none-any.whl", hash = "sha256:16e9b84e5326ba893a319a0323fcc8b4bcc9c71fc654fcabba0605596c673818", size = 11129, upload-time = "2025-06-24T07:13:58.993Z" }, +] + +[[package]] +name = "pylint-pydantic" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "pylint" }, + { name = "pylint-plugin-utils" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/ac/5de3c91c7f9354444af251f053cc9953c89cce1defa74b907f67be4f770a/pylint_pydantic-0.4.1-py3-none-any.whl", hash = "sha256:d1b937abe5c346d38de69ee1ada80c93d38ee2356addbabb687e2eb44036ac93", size = 16161, upload-time = "2025-10-27T08:03:33.641Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -1577,6 +1674,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + [[package]] name = "ty" version = "0.0.1a32" diff --git a/docs/research/CLAUDE-CODE-PROGRAMMATIC.md b/docs/research/CLAUDE-CODE-PROGRAMMATIC.md deleted file mode 100644 index f0192e8..0000000 --- a/docs/research/CLAUDE-CODE-PROGRAMMATIC.md +++ /dev/null @@ -1,1054 +0,0 @@ -# Claude Code Programmatic Usage Research - -**Research Date:** 2025-12-14 -**Purpose:** Comprehensive investigation of using Claude Code programmatically as a backend API for FastAPI applications - ---- - -## Executive Summary - -Claude Code can be used programmatically through three primary approaches: - -1. **CLI with `-p` flag** - Direct subprocess calls for simple automation -2. **Claude Agent SDK** - Production-ready library (Python/TypeScript) that wraps the CLI -3. **FastAPI Integration** - REST API wrapper around Claude Code execution - -**Key Finding:** The Claude Agent SDK (formerly Claude Code SDK) is the recommended approach for FastAPI backend integration. It spawns the Claude CLI as a subprocess and handles authentication, tool execution, session management, and streaming automatically. - -**Context Access:** Claude Code can access database/application context through: -- **Direct prompt strings** (simple, works immediately) -- **MCP servers** (advanced, for repeated database access and efficient token usage) -- **Combination approach** (MCP for data sources, prompts for specific queries) - -**Authentication:** Production deployments should use `ANTHROPIC_API_KEY` environment variable for pay-per-use API billing. Subscription-based authentication requires browser OAuth and is not suitable for headless server environments. - ---- - -## 1. Calling Claude Code from Python Backend - -### Solution Summary - -Use the **Claude Agent SDK for Python** rather than raw subprocess calls. The SDK provides a production-ready abstraction over the Claude Code CLI with built-in tool execution, session management, and streaming support. - -### Detailed Analysis - -#### Understanding the Architecture - -The Claude Agent SDK follows this architecture: - -``` -Your FastAPI App → Claude Agent SDK (Python) → Claude CLI (subprocess) → Anthropic API -``` - -> "The SDK spawns the CLI as a subprocess; the CLI talks to the Anthropic API." - [Claude Code Python SDK](https://adrianomelo.com/posts/claude-code-python-sdk.html) - -The SDK abstracts subprocess management, JSON streaming, and message parsing while providing a rich type system for structured interactions. - -#### Installation and Setup - -**Step 1: Install Claude Code CLI** - -```bash -# macOS/Linux/WSL -curl -fsSL https://claude.ai/install.sh | bash - -# Or via npm -npm install -g @anthropic-ai/claude-code - -# Or via Homebrew (macOS) -brew install --cask claude-code -``` - -**Step 2: Install Claude Agent SDK** - -```bash -pip install claude-agent-sdk -``` - -**Step 3: Set Authentication** - -```bash -export ANTHROPIC_API_KEY=your-api-key-here -``` - -Get your API key from the [Anthropic Console](https://console.anthropic.com/). - -#### Basic Usage Pattern - -```python -import asyncio -from claude_agent_sdk import query, ClaudeAgentOptions - -async def main(): - async for message in query( - prompt="Find and fix the bug in auth.py", - options=ClaudeAgentOptions(allowed_tools=["Read", "Edit", "Bash"]) - ): - print(message) - -asyncio.run(main()) -``` - -#### FastAPI Integration Example - -```python -from fastapi import FastAPI, BackgroundTasks -from pydantic import BaseModel -from claude_agent_sdk import query, ClaudeAgentOptions -import asyncio - -app = FastAPI() - -class PromptRequest(BaseModel): - prompt: str - context: str | None = None - -class ClaudeResponse(BaseModel): - content: str - session_id: str | None = None - -@app.post("/api/claude/generate", response_model=ClaudeResponse) -async def generate_content(request: PromptRequest): - full_prompt = f"{request.context}\n\n{request.prompt}" if request.context else request.prompt - - result_content = "" - session_id = None - - async for message in query( - prompt=full_prompt, - options=ClaudeAgentOptions( - allowed_tools=["Read", "Write", "Edit", "Bash"], - permission_mode="bypassPermissions" - ) - ): - if hasattr(message, 'content'): - result_content += str(message.content) - if hasattr(message, 'session_id'): - session_id = message.session_id - - return ClaudeResponse(content=result_content, session_id=session_id) -``` - -### Alternative Approach: Direct CLI Usage - -For simpler use cases, you can call the CLI directly using the `-p` flag: - -```python -import subprocess -import json - -def call_claude_cli(prompt: str) -> dict: - """ - Call Claude Code CLI with -p flag for headless execution - """ - proc = subprocess.run( - ["claude", "-p", prompt, "--output-format", "json"], - capture_output=True, - text=True, - timeout=300 - ) - - if proc.returncode == 0: - return json.loads(proc.stdout) - else: - raise RuntimeError(f"Claude CLI failed: {proc.stderr}") - -result = call_claude_cli("What files are in this directory?") -``` - -**CLI Flags for Programmatic Use:** - -| Flag | Purpose | -|------|---------| -| `-p` or `--print` | Non-interactive mode, prints response and exits | -| `--output-format json` | Structured JSON output for parsing | -| `--output-format stream-json` | Streaming JSON events | -| `--max-turns N` | Limit agentic turns in non-interactive mode | -| `--verbose` | Enable verbose logging for debugging | -| `--system-prompt "text"` | Replace entire system prompt | -| `--append-system-prompt "text"` | Add to default prompt | - -### Production Best Practices - -1. **Use the SDK over raw subprocess calls** - Better error handling, type safety, session management -2. **Set timeouts** - Prevent hanging requests -3. **Handle streaming** - Process responses incrementally for better UX -4. **Use async/await** - Leverage FastAPI's async capabilities -5. **Implement retry logic** - Handle API rate limits and transient failures -6. **Monitor token usage** - Track costs and optimize context - ---- - -## 2. Database/Context Access for Programmatic Claude Code - -### Solution Summary - -Claude Code has two primary methods for accessing database and application context: - -1. **Direct Prompt Strings** - Pass context directly in the prompt (simple, immediate) -2. **MCP Servers** - Connect to databases via Model Context Protocol (efficient, reusable) - -For production FastAPI backends, a **hybrid approach** works best: use MCP servers for persistent database connections and direct prompts for specific query parameters. - -### Detailed Analysis - -#### Method 1: Passing Context via Prompt Strings - -**How It Works:** - -> "Claude Code supports several methods for providing data: copy and paste directly into prompts (the most common approach), piping into Claude Code (e.g., cat foo.txt | claude) for logs, CSVs, and large data, or telling Claude to pull data via bash commands." - [Claude Code Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices) - -**Example: FastAPI Backend Loading Database Context** - -```python -from fastapi import FastAPI -from sqlalchemy.ext.asyncio import AsyncSession -from claude_agent_sdk import query, ClaudeAgentOptions - -@app.post("/api/generate-email") -async def generate_email(user_id: int, db: AsyncSession): - user = await db.execute( - select(User).where(User.id == user_id) - ) - user_data = user.scalar_one() - - context = f""" - User Information: - - Name: {user_data.name} - - Email: {user_data.email} - - Purchase History: {user_data.orders} - - Preferences: {user_data.preferences} - """ - - prompt = "Write a personalized marketing email for this user" - - async for message in query( - prompt=f"{context}\n\n{prompt}", - options=ClaudeAgentOptions(allowed_tools=[]) - ): - # Process response - pass -``` - -**Advantages:** -- Simple and straightforward -- Works immediately without setup -- Full control over context - -**Disadvantages:** -- Repetitive data loading for similar queries -- Can consume large amounts of tokens -- No caching or optimization - -> "Loading entire directories into Claude for every request can be very expensive." - [Claude Context Optimization](https://github.com/zilliztech/claude-context) - -#### Method 2: MCP Server for Database Access - -**How It Works:** - -> "Claude Code can connect to hundreds of external tools and data sources through the Model Context Protocol (MCP), an open source standard for AI-tool integrations, giving Claude Code access to tools, databases, and APIs." - [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp) - -**MCP Architecture:** - -``` -Claude Code (Client) → MCP Server → Your Database/API -``` - -The MCP server exposes database operations as "tools" that Claude Code can call programmatically. - -**Example: Database MCP Server** - -> "DBHub by Bytebase is an open-source MCP server that can expose databases (Postgres, MySQL, etc.) to Claude, allowing Claude to directly query the database for schema info or even data." - [Claude Code Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices) - -**Setting Up MCP Server for PostgreSQL:** - -```python -from claude_agent_sdk import query, ClaudeAgentOptions - -async def main(): - async for message in query( - prompt="Find emails of 10 random users who used feature ENG-4521", - options=ClaudeAgentOptions( - mcp_servers={ - "postgres": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-postgres"], - "env": { - "POSTGRES_URL": "postgresql://user:pass@localhost:5432/db" - } - } - } - ) - ): - print(message) -``` - -**Token Efficiency:** - -> "Instead of loading entire directories into Claude for every request, Claude Context efficiently stores your codebase in a vector database and only uses related code in context to keep costs manageable." - [Claude Context](https://github.com/zilliztech/claude-context) - -**Context Usage Comparison:** - -- **With all MCP tools enabled:** 143k/200k tokens (72%) - MCP tools consuming 82.0k tokens (41.0%) -- **With selective MCP tools:** 67k/200k tokens (34%) - MCP tools taking only 5.7k tokens (2.8%) - -#### Method 3: Hybrid Approach (Recommended for FastAPI) - -**Best Practice Pattern:** - -1. Configure MCP server for database connection (one-time setup) -2. Pass specific query parameters via prompt -3. Let Claude use MCP tools to fetch relevant data -4. Claude processes and returns results - -**Example Implementation:** - -```python -from fastapi import FastAPI -from claude_agent_sdk import query, ClaudeAgentOptions - -app = FastAPI() - -@app.post("/api/claude/analyze-users") -async def analyze_users(feature_id: str): - async for message in query( - prompt=f"Analyze user engagement for feature {feature_id} using the database", - options=ClaudeAgentOptions( - mcp_servers={ - "postgres": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-postgres"], - "env": { - "POSTGRES_URL": os.getenv("DATABASE_URL") - } - } - }, - allowed_tools=["Read", "Bash"] - ) - ): - # Process streaming response - pass -``` - -### When to Use Which Approach - -| Use Case | Recommended Method | Reason | -|----------|-------------------|--------| -| One-off queries with small data | Direct prompt | Simple, no setup needed | -| Repeated database queries | MCP server | Token efficient, reusable | -| Large datasets | MCP server | Avoids context window limits | -| Mixed sources (DB + files + APIs) | Hybrid | Best of both worlds | -| Production FastAPI backend | Hybrid | Flexible and efficient | - ---- - -## 3. MCP Server Architecture for Programmatic Usage - -### Solution Summary - -When using Claude Code programmatically via subprocess/SDK, you do **NOT** need to mount MCP servers on your FastAPI app (no `app.mount("/mcp", mcp)`). Instead, MCP servers are configured **within the Claude Agent SDK options** and run as separate processes that Claude Code connects to directly. - -### Detailed Analysis - -#### Understanding "Who Calls Who" - -**Critical Distinction:** - -1. **fastapi-mcp** - Exposes your FastAPI endpoints AS MCP tools FOR Claude to call -2. **Claude Agent SDK mcp_servers option** - Connects Claude TO external MCP servers - -These are two different patterns: - -**Pattern A: Claude calls your FastAPI tools (via fastapi-mcp)** -``` -User → Claude Desktop → MCP Proxy → Your FastAPI App (with fastapi-mcp) -``` - -**Pattern B: Your FastAPI app calls Claude (which uses MCP servers)** -``` -User → Your FastAPI App → Claude Agent SDK → Claude CLI → External MCP Server → Database -``` - -For your use case (FastAPI backend calling Claude Code), you want **Pattern B**. - -#### MCP Server Architecture Explanation - -> "The Model Context Protocol defines a client-server architecture for tool use. An MCP server wraps an external service or data source behind a common protocol (with defined actions, or 'tools'), while an MCP client (like Claude Code or other AI agent frameworks) connects to the server to invoke those tools." - [Claude Code as an MCP Server](https://www.ksred.com/claude-code-as-an-mcp-server-an-interesting-capability-worth-understanding/) - -**Call Flow:** - -``` -1. User clicks button in React -2. React → FastAPI endpoint -3. FastAPI → Claude Agent SDK (query function) -4. SDK spawns Claude CLI subprocess -5. Claude CLI connects to MCP server (separate process) -6. MCP server queries PostgreSQL -7. Results flow back through the chain -``` - -> "Client sends user message to the model. Model analyzes context and decides to call a tool exposed by MCP (or multiple tools). Client forwards the tool call to the MCP server over the chosen transport. Server executes the tool and returns results. Model receives tool output and composes the final answer to the user." - [Create a MCP Server for Claude Code](https://www.cometapi.com/create-a-mcp-server-for-claude-code/) - -#### Configuration in FastAPI Backend - -**You configure MCP servers in the SDK options, not on the FastAPI app:** - -```python -from fastapi import FastAPI -from claude_agent_sdk import query, ClaudeAgentOptions -import os - -app = FastAPI() - -@app.post("/api/generate") -async def generate_content(prompt: str): - async for message in query( - prompt=prompt, - options=ClaudeAgentOptions( - mcp_servers={ - "postgres": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-postgres"], - "env": { - "POSTGRES_URL": os.getenv("DATABASE_URL") - } - }, - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"] - } - } - ) - ): - yield message -``` - -**No `app.mount("/mcp", mcp)` needed** because: -- MCP servers are spawned as child processes by the Claude CLI -- They communicate via stdio/SSE/HTTP transports -- They're not part of your FastAPI application's HTTP routes - -#### When Would You Use fastapi-mcp? - -You would use `fastapi-mcp` if you wanted to **expose your FastAPI endpoints to Claude Desktop/CLI as tools**: - -```python -from fastapi import FastAPI -from fastapi_mcp import FastApiMCP - -app = FastAPI() - -@app.get("/users/{user_id}") -async def get_user(user_id: int): - return {"user": user_id} - -mcp = FastApiMCP(app=app) -mcp.mount() - -``` - -Then configure Claude Desktop to use your API: - -```json -{ - "mcpServers": { - "my-api": { - "command": "mcp-proxy", - "args": ["http://localhost:8000/mcp"] - } - } -} -``` - -> "The mcp-proxy proxies requests to a remote MCP server over SSE transport, wrapping your real server and translating Claude's SSE-based requests into regular HTTP requests that your FastAPI app understands." - [FastAPI-MCP Quick Start](https://thedocs.io/fastapi_mcp/quick_start/) - -**This is NOT what you want for your use case.** You want Claude Code to call external MCP servers (like database), not to call your FastAPI app. - -#### Claude Code as Both MCP Client and Server - -Interestingly, Claude Code can act as both: - -> "You can run claude mcp serve to expose Claude Code while Claude Code itself connects to GitHub and Postgres MCP servers. This means Claude Code can act as **both** an MCP client and an MCP server simultaneously, allowing for layered agent architectures." - [Claude Code as an MCP Server](https://www.ksred.com/claude-code-as-an-mcp-server-an-interesting-capability-worth-understanding/) - -But for your FastAPI backend use case, Claude Code is purely an MCP **client** connecting to database/tool servers. - ---- - -## 4. Production Deployment Considerations - -### Solution Summary - -For production deployment of Claude Code in a FastAPI backend: - -1. **Container Strategy:** Same container as FastAPI (simpler) or separate container (better isolation) -2. **Authentication:** Use `ANTHROPIC_API_KEY` environment variable (not subscription-based OAuth) -3. **Billing:** API pay-per-use is recommended for production over subscription plans -4. **Headless Mode:** Fully supported via environment variable authentication - -### Detailed Analysis - -#### Container Deployment Options - -**Option 1: Same Container (Recommended for MVP)** - -```dockerfile -FROM python:3.12-slim - -RUN apt-get update && apt-get install -y curl && \ - curl -fsSL https://claude.ai/install.sh | bash - -COPY requirements.txt . -RUN pip install -r requirements.txt - -COPY . . - -ENV ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} -ENV DATABASE_URL=${DATABASE_URL} - -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] -``` - -**Advantages:** -- Simpler deployment -- Fewer network hops -- Lower latency - -**Disadvantages:** -- Larger container image -- Tight coupling - -**Option 2: Separate Container (Recommended for Production)** - -```yaml - -services: - backend: - build: ./backend - environment: - - CLAUDE_SERVICE_URL=http://claude-service:8001 - depends_on: - - claude-service - - postgres - - claude-service: - build: ./claude-service - environment: - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - volumes: - - ./workspace:/workspace - - postgres: - image: postgres:16 - environment: - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} -``` - -**Claude Service Implementation:** - -```python - -from fastapi import FastAPI -from claude_agent_sdk import query, ClaudeAgentOptions - -app = FastAPI() - -@app.post("/execute") -async def execute_claude(prompt: str, context: dict = None): - async for message in query( - prompt=prompt, - options=ClaudeAgentOptions( - mcp_servers={ - "postgres": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-postgres"], - "env": {"POSTGRES_URL": os.getenv("DATABASE_URL")} - } - } - ) - ): - yield message -``` - -**Advantages:** -- Better isolation -- Independent scaling -- Cleaner architecture -- Can reuse claude-service across multiple apps - -#### Authentication in Containerized Environments - -**Critical Finding:** Subscription-based authentication (Claude Pro/Max) is NOT suitable for production containers. - -> "The SDK also supports authentication via third-party API providers like Amazon Bedrock (set CLAUDE_CODE_USE_BEDROCK=1 environment variable) and Google Vertex AI (set CLAUDE_CODE_USE_VERTEX=1 environment variable)." - [Claude Code SDK Python Authentication](https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code) - -**Authentication Hierarchy:** - -1. **ANTHROPIC_API_KEY (Recommended for Production)** - ```bash - export ANTHROPIC_API_KEY=sk-ant-api03-... - ``` - - Pay-per-use billing - - Headless compatible - - No browser OAuth required - - Works in containers - -2. **Subscription (Claude Pro/Max) - NOT for containers** - - Requires browser login (`claude auth login`) - - OAuth flow with callback - - Not suitable for headless servers - - Container deployment issues - -3. **Long-lived Access Tokens (Alternative)** - ```bash - claude setup-token - export CLAUDE_CODE_OAUTH_TOKEN= - ``` - - Works in containers - - Linked to subscription - - Can be mounted as secret - -**Docker Volume Mounting (Alternative to Environment Variables):** - -> "For Docker containers, you can mount the credential file as a read-only volume using `docker run -v ~/.config/claude-code/auth.json:/root/.config/claude-code/auth.json:ro -it my-dev-image`." - [Claude Code Docker Complete Guide](https://smartscope.blog/en/generative-ai/claude/claude-code-docker-guide/) - -**Recommended Production Pattern:** - -```dockerfile -FROM python:3.12-slim - -RUN curl -fsSL https://claude.ai/install.sh | bash -RUN pip install claude-agent-sdk - -ENV ANTHROPIC_API_KEY="" - -CMD ["python", "app.py"] -``` - -```bash -docker run -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY myapp -``` - -#### Headless Authentication Issues (2025 Status) - -**Known Issues:** - -> "There are known issues with headless mode authentication: Any time I attempt to run claude via claude -p 'test' it returns Invalid API key · Fix external API key. In a non-interactive environment, the CLI still requires executing /login even though an API key is provided." - [GitHub Issue #5666](https://github.com/anthropics/claude-code/issues/5666) - -**Workaround:** - -The Claude Agent SDK handles authentication better than raw CLI calls. Use the SDK instead of direct `claude -p` commands: - -```python - -from claude_agent_sdk import query, ClaudeAgentOptions -import os - -os.environ["ANTHROPIC_API_KEY"] = "your-key" - -async for message in query( - prompt="test", - options=ClaudeAgentOptions(setting_sources=None) -): - print(message) -``` - -**Verification:** - -> "Run /status in Claude Code periodically to verify your current authentication method. To verify if an API key is set as an environment variable, run /status in Claude Code." - [Managing API Key Environment Variables](https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code) - -#### Cost Comparison: Subscription vs API - -**Claude Max Subscription:** -- $100/month → 5x usage of Pro -- $200/month → 20x usage of Pro (Opus 4.5 access) - -**Anthropic API:** -- Claude Opus 4.5: $5.00/M input tokens, $25.00/M output tokens -- Claude Sonnet 4.5: $3.00/M input, $15.00/M output (≤200K context) -- Claude Sonnet 4.5: $6.00/M input, $22.50/M output (>200K context) - -**Value Analysis:** - -> "If you max out the $100 Max plan, that's approximately $7,400 worth of API usage equivalent, 74x the amount you pay. You can get $150 worth of API usage for the price of $20 a month with a Claude Pro subscription." - [LLM Subscriptions vs APIs Value](https://www.asad.pw/llm-subscriptions-vs-apis-value-for-money/) - -**Recommendation:** - -> "API is best for production deployments, automated pipelines, backends, or when you want to control exact spend. The API removes per-seat fees and allows unlimited developers behind a single deployment, but you pay per token." - [Claude Code Usage Limits and Subscription Plans](https://www.geeky-gadgets.com/claude-code-usage-limits-pricing-plans-guide-sept-2025/) - -**When to Use Each:** - -| Scenario | Recommendation | Reason | -|----------|----------------|--------| -| Development | Claude Max Subscription | Predictable costs, generous limits | -| CI/CD Automation | Anthropic API | Headless compatible | -| Production Backend | Anthropic API | Pay-per-use, scales with traffic | -| Multiple Developers | Anthropic API | No per-seat fees | -| High-volume | Anthropic API | Better cost control | -| Prototype/MVP | Claude Max Subscription | Faster setup | - ---- - -## 5. Complete Architecture Pattern - -### End-to-End Flow - -``` -┌─────────────┐ -│ React │ -│ Frontend │ -└──────┬──────┘ - │ 1. User clicks button - │ - ▼ -┌─────────────────────────────────────┐ -│ FastAPI Backend │ -│ ┌──────────────────────────────┐ │ -│ │ 2. Load context from │ │ -│ │ PostgreSQL │ │ -│ │ │ │ -│ │ user_data = await db.query() │ │ -│ └──────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ 3. Call Claude Agent SDK │ │ -│ │ │ │ -│ │ async for msg in query( │ │ -│ │ prompt=prompt, │ │ -│ │ options={ │ │ -│ │ mcp_servers={ │ │ -│ │ "postgres": {...} │ │ -│ │ } │ │ -│ │ } │ │ -│ │ ) │ │ -│ └──────────────────────────────┘ │ -└──────────┬──────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ Claude Agent SDK │ -│ (Python Library) │ -└──────────┬──────────────┘ - │ 4. Spawns subprocess - │ - ▼ -┌─────────────────────────┐ -│ Claude Code CLI │ -│ (claude binary) │ -└──────────┬──────────────┘ - │ 5. API request - │ - ▼ -┌─────────────────────────┐ -│ Anthropic API │ -│ (Claude Opus/Sonnet) │ -└──────────┬──────────────┘ - │ - │ 6. Needs data - │ - ▼ -┌─────────────────────────┐ -│ MCP Server │ -│ (Database Proxy) │ -└──────────┬──────────────┘ - │ 7. SQL query - │ - ▼ -┌─────────────────────────┐ -│ PostgreSQL Database │ -└─────────────────────────┘ -``` - -### Complete Implementation Example - -```python - -from fastapi import FastAPI, HTTPException -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -from sqlalchemy.orm import sessionmaker -from claude_agent_sdk import query, ClaudeAgentOptions -import os -import json - -app = FastAPI() - -DATABASE_URL = os.getenv("DATABASE_URL") -engine = create_async_engine(DATABASE_URL) -AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - -class GenerateRequest(BaseModel): - user_id: int - prompt: str - -async def get_db(): - async with AsyncSessionLocal() as session: - yield session - -@app.post("/api/generate-email") -async def generate_email(request: GenerateRequest): - async with AsyncSessionLocal() as db: - result = await db.execute( - select(User).where(User.id == request.user_id) - ) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException(status_code=404, detail="User not found") - - context = f""" - User Profile: - - Name: {user.name} - - Email: {user.email} - - Join Date: {user.created_at} - - Total Orders: {len(user.orders)} - - Preferences: {user.preferences} - """ - - full_prompt = f"{context}\n\n{request.prompt}" - - async def stream_response(): - async for message in query( - prompt=full_prompt, - options=ClaudeAgentOptions( - allowed_tools=[], - permission_mode="bypassPermissions", - mcp_servers={ - "postgres": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-postgres"], - "env": { - "POSTGRES_URL": DATABASE_URL - } - } - } - ) - ): - if hasattr(message, 'content'): - yield json.dumps({"content": message.content}) + "\n" - - return StreamingResponse(stream_response(), media_type="application/x-ndjson") - -@app.post("/api/analyze-database") -async def analyze_database(query_prompt: str): - async def stream_response(): - async for message in query( - prompt=f"Using the postgres MCP server, {query_prompt}", - options=ClaudeAgentOptions( - mcp_servers={ - "postgres": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-postgres"], - "env": { - "POSTGRES_URL": os.getenv("DATABASE_URL") - } - } - } - ) - ): - if hasattr(message, 'content'): - yield json.dumps({"data": message.content}) + "\n" - - return StreamingResponse(stream_response(), media_type="application/x-ndjson") - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) -``` - -### React Frontend Integration - -```typescript - -import { useState } from 'react'; - -export function EmailGenerator() { - const [loading, setLoading] = useState(false); - const [content, setContent] = useState(''); - - const handleGenerate = async () => { - setLoading(true); - setContent(''); - - const response = await fetch('/api/generate-email', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user_id: 123, - prompt: 'Write a personalized welcome email' - }) - }); - - const reader = response.body?.getReader(); - const decoder = new TextDecoder(); - - while (true) { - const { done, value } = await reader!.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split('\n').filter(Boolean); - - for (const line of lines) { - const data = JSON.parse(line); - setContent(prev => prev + data.content); - } - } - - setLoading(false); - }; - - return ( -
- -
{content}
-
- ); -} -``` - -### Docker Compose Setup - -```yaml - -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: myapp - volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" - - backend: - build: ./backend - environment: - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD}@postgres:5432/myapp - depends_on: - - postgres - ports: - - "8000:8000" - volumes: - - ./backend:/app - - frontend: - build: ./frontend - environment: - - VITE_API_URL=http://localhost:8000 - ports: - - "5173:5173" - volumes: - - ./frontend:/app - -volumes: - postgres_data: -``` - -### Backend Dockerfile - -```dockerfile - -FROM python:3.12-slim - -WORKDIR /app - -RUN apt-get update && \ - apt-get install -y curl nodejs npm git && \ - curl -fsSL https://claude.ai/install.sh | bash && \ - apt-get clean - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -ENV PYTHONUNBUFFERED=1 - -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] -``` - ---- - -## Key Takeaways - -### 1. Use the Claude Agent SDK (Not Raw CLI) - -The SDK provides production-ready abstractions over subprocess management, authentication, streaming, and error handling. It's the recommended approach for FastAPI backends. - -### 2. MCP Servers Are Optional But Powerful - -For simple prompts with small context, pass data directly in the prompt. For repeated database access or large datasets, configure MCP servers within the SDK options. - -### 3. MCP Servers Are NOT Mounted on FastAPI - -You don't use `app.mount("/mcp", mcp)` for this use case. MCP servers are configured in the `ClaudeAgentOptions` and run as separate processes that Claude Code connects to. - -### 4. Use API Key Authentication for Production - -Set `ANTHROPIC_API_KEY` environment variable. Subscription-based authentication (Claude Pro/Max) requires browser OAuth and doesn't work well in containerized headless environments. - -### 5. API Billing Is Better for Production - -Pay-per-use API billing scales better than subscriptions for production backends. It removes per-seat fees and provides precise cost control. - -### 6. Streaming Is Important for UX - -Implement streaming responses to show progress to users. The SDK supports async streaming out of the box. - -### 7. Session Management Enables Context - -Use session IDs to maintain conversation context across multiple requests. This enables multi-turn interactions. - ---- - -## Sources and References - -### Official Documentation -- [Claude Agent SDK Overview](https://platform.claude.com/docs/en/api/agent-sdk/overview) - Official SDK documentation -- [Claude Code CLI Reference](https://code.claude.com/docs/en/cli-reference) - CLI flags and programmatic usage -- [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp) - MCP server configuration -- [Managing API Key Environment Variables](https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code) - Authentication methods -- [Using Claude Code with your Pro or Max plan](https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan) - Subscription integration - -### Technical Guides -- [Claude Code Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices) - Official best practices for agentic coding -- [Building Agents with the Claude Agent SDK](https://www.anthropic.com/engineering/building-agents-with-the-claude-agent-sdk) - Agent development patterns -- [Introducing the Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) - MCP standard explanation - -### Integration Examples -- [claude-code-fastapi by e2b-dev](https://github.com/e2b-dev/claude-code-fastapi) - FastAPI wrapper for Claude Code with E2B sandboxes -- [claude-code-api by codingworkflow](https://github.com/codingworkflow/claude-code-api) - OpenAI-compatible API gateway -- [fastapi-nextjs-docker-github-actions-reference](https://github.com/raveenb/fastapi-nextjs-docker-github-actions-reference) - Full-stack reference with CI/CD - -### MCP Resources -- [FastAPI-MCP by tadata-org](https://github.com/tadata-org/fastapi_mcp) - Expose FastAPI endpoints as MCP tools -- [How to Use FastAPI MCP Server](https://huggingface.co/blog/lynn-mikami/fastapi-mcp-server) - Integration guide -- [Model Context Protocol Servers](https://github.com/modelcontextprotocol/servers) - Official MCP server implementations - -### Cost and Deployment -- [Claude Pricing Explained](https://intuitionlabs.ai/articles/claude-pricing-plans-api-costs) - Subscription vs API comparison -- [LLM Subscriptions vs APIs Value](https://www.asad.pw/llm-subscriptions-vs-apis-value-for-money/) - Cost analysis -- [Claude Code Docker Complete Guide](https://smartscope.blog/en/generative-ai/claude/claude-code-docker-guide/) - Containerization patterns -- [Configure Claude Code | Docker Docs](https://docs.docker.com/ai/sandboxes/claude-code/) - Official Docker integration - -### Community Resources -- [Claude Code Python SDK Guide](https://adrianomelo.com/posts/claude-code-python-sdk.html) - SDK architecture explanation -- [A Practical Guide to the Python Claude Code SDK](https://www.eesel.ai/blog/python-claude-code-sdk) - 2025 SDK guide -- [Optimising MCP Server Context Usage](https://scottspence.com/posts/optimising-mcp-server-context-usage-in-claude-code) - Token optimization -- [Claude Code as an MCP Server](https://www.ksred.com/claude-code-as-an-mcp-server-an-interesting-capability-worth-understanding/) - Dual role explanation - -### GitHub Issues -- [Issue #5666: Invalid API Key in headless mode](https://github.com/anthropics/claude-code/issues/5666) - Authentication challenges -- [Issue #7100: Document Headless/Remote Authentication](https://github.com/anthropics/claude-code/issues/7100) - CI/CD authentication -- [Issue #1736: Re-authenticating in Docker container](https://github.com/anthropics/claude-code/issues/1736) - Container deployment - ---- - -**Research Completed:** 2025-12-14 -**Last Updated:** 2025-12-14 -**Status:** Comprehensive - Ready for implementation diff --git a/docs/research/CLAUDE-MAX-SUBSCRIPTION-HEADLESS.md b/docs/research/CLAUDE-MAX-SUBSCRIPTION-HEADLESS.md deleted file mode 100644 index cd3f9c7..0000000 --- a/docs/research/CLAUDE-MAX-SUBSCRIPTION-HEADLESS.md +++ /dev/null @@ -1,725 +0,0 @@ -# Claude Max Subscription Programmatic Access Research - -**Research Date:** 2025-12-14 -**Focus:** Using Claude Max ($200/month) subscription programmatically in headless FastAPI backend environments -**Critical Question:** Can we use Max subscription instead of paying additional API costs? - ---- - -## Executive Summary - -**ANSWER: YES, BUT WITH SIGNIFICANT CAVEATS** - -Claude Max subscriptions CAN be used programmatically in headless environments through Claude Code's OAuth authentication system. However, this approach exists in a **policy gray area** and has several production limitations: - -### Key Findings: -1. **Technical Feasibility:** ✅ Possible via `CLAUDE_CODE_OAUTH_TOKEN` environment variable -2. **Official Support:** ⚠️ Limited - designed for interactive use, headless support is undocumented -3. **Terms of Service:** ⚠️ Unclear if automated usage violates ToS for consumer subscriptions -4. **Production Viability:** ⚠️ OAuth tokens expire (8-12 hours), requiring refresh mechanisms -5. **Cost Savings:** ✅ Significant - Max subscription vs. per-token API pricing - ---- - -## Solution Summary - -Claude Max subscriptions provide programmatic access through **Claude Code** using OAuth 2.0 authentication. You can authenticate in headless environments by: - -1. Running `claude setup-token` to generate long-lived OAuth tokens -2. Injecting tokens via `CLAUDE_CODE_OAUTH_TOKEN` environment variable in Docker containers -3. Mounting `~/.claude/.credentials.json` as a volume for persistent authentication -4. Using the unofficial `claude_max` Python package that wraps this authentication - -**However:** This approach is NOT officially documented for production server use and may violate consumer subscription terms of service. Anthropic's official position is that API usage should use the separate Anthropic API with commercial terms. - ---- - -## Detailed Analysis - -### 1. Authentication Methods for Headless Environments - -#### Option A: OAuth Token Environment Variable - -**How it works:** -```bash -# Generate token interactively -claude setup-token - -# Export token for headless use -export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-your-token-here" - -# Run Claude Code programmatically -claude status -``` - -**Token Format:** -- Access tokens: `sk-ant-oat01-...` (expires in 8-12 hours) -- Refresh tokens: `sk-ant-ort01-...` (longer-lived, but also expires) - -**Docker Usage:** -```bash -docker run --rm -it \ - -e CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..." \ - -v $(pwd):/app \ - your-fastapi-image -``` - -**Sources:** -- [Setup Container Authentication - Claude Did This](https://claude-did-this.com/claude-hub/getting-started/setup-container-guide) -- [GitHub Issue #7100 - Headless Authentication Documentation](https://github.com/anthropics/claude-code/issues/7100) -- [Claude Code SDK Docker Repository](https://github.com/cabinlab/claude-code-sdk-docker) - ---- - -#### Option B: Mount Authentication Credentials Volume - -**Directory Structure:** -``` -~/.claude/ -├── .credentials.json # OAuth tokens (access + refresh) -├── settings.local.json # User preferences -└── [project data] -``` - -**Docker Compose Example:** -```yaml -services: - fastapi: - image: your-fastapi-image - volumes: - - ~/.claude:/root/.claude:ro # Mount read-only for security - - ./app:/app -``` - -**Advantages:** -- Automatic token refresh handled by Claude Code -- No need to manually extract tokens -- More secure than environment variables - -**Disadvantages:** -- Requires initial interactive authentication -- Credentials tied to host machine -- Not suitable for cloud deployments without pre-setup - -**Sources:** -- [Docker Docs - Configure Claude Code](https://docs.docker.com/ai/sandboxes/claude-code/) -- [GitHub Issue #1736 - Avoiding Re-authentication](https://github.com/anthropics/claude-code/issues/1736) -- [Medium - Running Claude Code in Docker Containers](https://medium.com/rigel-computer-com/running-claude-code-in-docker-containers-one-project-one-container-1601042bf49c) - ---- - -#### Option C: claude_max Python Package - -**What it is:** -An unofficial Python package published to PyPI (June 15, 2025) that programmatically accesses Claude Code's authentication system to use Max subscriptions for API-style completions. - -**How it works:** -- Implements OAuth 2.0 with PKCE security -- Extracts authentication from Claude Code -- Provides API-compatible interface using subscription credits - -**Usage Pattern:** -```python -from claude_max import ClaudeMax - -# Initialize with Max subscription credentials -client = ClaudeMax() - -# Make API-style calls using subscription -response = client.complete( - model="claude-opus-4-5", - messages=[{"role": "user", "content": "Hello"}] -) -``` - -**Critical Warning:** -> "Claude Max subscribers pay $200/month, yet there's no official way to use subscriptions for automation, with the only workaround involving fragile OAuth token extraction that may violate ToS." - -**Sources:** -- [How I Built claude_max - Substack Article](https://idsc2025.substack.com/p/how-i-built-claude_max-to-unlock) -- [Maximizing Claude Max Subscription - Deeplearning.fr](https://deeplearning.fr/maximizing-your-claude-max-subscription-complete-guide-to-automated-workflows-with-claude-code-and-windsurf/) - ---- - -### 2. Mobile Apps and Browser Wrappers Authentication - -**Research Question:** How do mobile Claude apps use Max subscription if not through API? - -**Finding:** Mobile apps and browser extensions use the **same OAuth 2.0 flow** as Claude Code: - -1. User logs in with claude.ai credentials -2. OAuth authorization flow with PKCE -3. Receives access token (`sk-ant-oat01-...`) and refresh token (`sk-ant-ort01-...`) -4. Stores tokens locally for subsequent requests -5. Automatically refreshes when access token expires - -**Key Insight:** -Mobile apps are **consumer-facing interactive applications**, which aligns with the Max subscription terms of service. A headless FastAPI backend is a **server-to-server automation**, which may NOT align with consumer subscription terms. - -**Authentication Endpoint:** -``` -POST https://console.anthropic.com/v1/oauth/token -{ - "grant_type": "refresh_token", - "refresh_token": "sk-ant-ort01-...", - "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e" -} -``` - -**Sources:** -- [Claude Code Provider - Roo Code Documentation](https://docs.roocode.com/providers/claude-code) -- [GitHub - claude-token-refresh Tool](https://github.com/RavenStorm-bit/claude-token-refresh) -- [Unlock Claude API from Claude Pro/Max](https://www.alif.web.id/posts/claude-oauth-api-key) - ---- - -### 3. Long-Lived Access Tokens from Claude Max - -#### Token Lifespan - -| Token Type | Prefix | Lifespan | Purpose | -|------------|--------|----------|---------| -| Access Token | `sk-ant-oat01-...` | 8-12 hours | Authenticate API requests | -| Refresh Token | `sk-ant-ort01-...` | Days to weeks | Obtain new access tokens | -| API Key | `sk-ant-api03-...` | Indefinite | Anthropic API (separate billing) | - -#### `claude setup-token` Command - -**Purpose:** Generate long-lived OAuth tokens for headless/CI/CD environments - -**Usage:** -```bash -# Interactive setup -claude setup-token - -# Output: -# "Your OAuth token: sk-ant-oat01-ABCxyz..." -# "Save this token securely - it provides full access to your account" - -# Use in environment -export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-ABCxyz..." -``` - -**Known Issues:** -- Tokens still expire after 8-12 hours -- No official documentation for production use -- Refresh token handling required for long-running services - -**Bug Reports:** -> "OAuth tokens expire during long-running autonomous tasks, causing 401 authentication_error failures that require manual /login intervention." - -**Sources:** -- [GitHub Issue #8938 - setup-token Not Enough to Authenticate](https://github.com/anthropics/claude-code/issues/8938) -- [GitHub Issue #12447 - OAuth Token Expiration Disrupts Workflows](https://github.com/anthropics/claude-code/issues/12447) -- [Elixir Mix Task Documentation](https://hexdocs.pm/claude_agent_sdk/Mix.Tasks.Claude.SetupToken.html) - ---- - -### 4. Subscription Usage vs API Usage - -#### How to Verify You're Using Subscription (Not API) - -**Method 1: `/status` Command** -```bash -claude status - -# Expected output for subscription: -# Authentication: Claude Max Subscription -# Usage: 45 of 900 messages remaining (resets in 3h 22m) -# Cost: Included in subscription - -# Expected output for API: -# Authentication: API Key -# Usage: $12.45 this month -# Cost: Pay-per-token -``` - -**Method 2: Check Environment Variables** -```bash -# Priority order (first found wins): -# 1. ANTHROPIC_API_KEY → Uses API (costs money) -# 2. CLAUDE_CODE_OAUTH_TOKEN → Uses subscription -# 3. ~/.claude/.credentials.json → Uses subscription - -# Ensure API key is NOT set: -echo $ANTHROPIC_API_KEY -# Should be empty for subscription use -``` - -**Method 3: Check Billing Dashboard** - -Subscription usage shows as: -- **$0.00 per request** in API console -- Messages count against 5-hour rolling window -- No per-token charges - -API usage shows as: -- **$X.XX per request** based on token count -- Cumulative monthly charges -- Detailed token breakdown - -**Rate Limits Comparison:** - -| Plan | Messages (5hr) | Prompts (5hr) | Weekly Capacity | -|------|---------------|---------------|-----------------| -| Max 5x ($100) | ~225 | 50-200 | 140-280hr Sonnet / 15-35hr Opus | -| Max 20x ($200) | ~900 | 200-800 | 240-480hr Sonnet / 24-40hr Opus | -| API | Unlimited* | Unlimited* | Based on tier/spending | - -*API has separate rate limits based on tier - -**Important Note:** -> "Both Pro and Max plans offer usage limits that are shared across Claude and Claude Code, meaning all activity in both tools counts against the same usage limits." - -**Sources:** -- [Using Claude Code with Pro or Max Plan - Claude Help](https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan) -- [About Claude's Max Plan Usage - Claude Help](https://support.claude.com/en/articles/11014257-about-claude-s-max-plan-usage) -- [GitHub Issue #1721 - Need Usage Gauge](https://github.com/anthropics/claude-code/issues/1721) -- [GitHub Issue #1287 - Misleading Cost Command Output](https://github.com/anthropics/claude-code/issues/1287) - ---- - -### 5. Terms of Service and Policy Analysis - -#### Official Anthropic Position - -**Subscription vs. API Separation:** -> "A paid Claude subscription enhances your chat experience but doesn't include access to the Claude API or Console, requiring separate sign-up for API usage." - -**Consumer vs. Commercial Terms:** -> "The consumer terms updates apply to users on Claude Free, Pro, and Max plans (including when they use Claude Code), but they do not apply to services under Commercial Terms, including API use." - -**Key Implication:** -Max subscriptions fall under **consumer terms**, which are designed for interactive human use. Headless server automation may be considered outside the intended use case. - -#### Policy Gray Area - -**The Problem:** -- Claude Code technically supports headless mode -- `CLAUDE_CODE_OAUTH_TOKEN` exists for automation -- But terms of service don't explicitly permit automated usage for consumer subscriptions - -**Community Concern:** -> "Claude Max subscribers pay $200/month, yet there's no official way to use subscriptions for automation... it's unclear if token extraction workarounds violate ToS. This situation undermines the value proposition of Claude Max for developers who want to integrate Claude Code into workflows." - -**Feature Request (GitHub Issue #1454):** -Title: "Feature Request: Machine to Machine Authentication for Claude Max Subscriptions" - -Status: Open (no official response confirming or denying legitimacy) - -#### Risk Assessment for Production Use - -| Risk Factor | Level | Mitigation | -|-------------|-------|------------| -| Account suspension | Medium | Use for personal projects, not enterprise | -| Token expiration | High | Implement refresh token logic | -| Policy changes | Medium | Monitor Anthropic announcements | -| Lack of support | High | No SLA for subscription-based automation | -| ToS violation | Unknown | Consult legal/Anthropic directly | - -**Recommended Approach:** -1. **For personal/development:** Use Max subscription with awareness of limitations -2. **For production/enterprise:** Use official Anthropic API with commercial terms -3. **For cost optimization:** Evaluate if Max subscription ($200/month) covers your usage vs. API costs - -**Sources:** -- [Feature Request #1454 - Machine to Machine Auth](https://github.com/anthropics/claude-code/issues/1454) -- [Why Pay Separately for API - Claude Help](https://support.anthropic.com/en/articles/9876003-i-have-a-paid-claude-subscription-pro-max-team-or-enterprise-plans-why-do-i-have-to-pay-separately-to-use-the-claude-api-and-console) -- [Updates to Consumer Terms - Anthropic News](https://www.anthropic.com/news/updates-to-our-consumer-terms) -- [Claude vs Claude API vs Claude Code - 16x Engineer](https://eval.16x.engineer/blog/claude-vs-claude-api-vs-claude-code) - ---- - -## Alternative Approaches (If Subscription Headless Doesn't Work) - -### Option 1: Hybrid Architecture - -**Design:** -- FastAPI backend uses official Anthropic API for production -- Claude Code (Max subscription) used for development/testing only -- Separate billing but predictable costs - -**Cost Structure:** -- Development: $200/month Max subscription -- Production: Pay-per-token API (budget based on usage) - ---- - -### Option 2: WebSocket Proxy to Local Claude Code - -**Architecture:** -``` -FastAPI Backend (Server) - ↓ WebSocket Connection -Local Claude Code Instance (Developer Machine) - ↓ OAuth Authentication -Claude Max Subscription -``` - -**Advantages:** -- Definitely uses Max subscription -- No ToS concerns (interactive use) - -**Disadvantages:** -- Not suitable for production deployment -- Requires developer machine always running -- Single point of failure - ---- - -### Option 3: Official Enterprise Plan - -**What it is:** -Enterprise plans may have different terms allowing automated usage. - -**Next Steps:** -Contact Anthropic sales to inquire about: -- Enterprise API access using subscription model -- Custom rate limits -- Commercial terms for automated workflows - -**Sources:** -- [Claude Pricing - Anthropic](https://claude.com/pricing) - ---- - -## Production Implementation Guide - -### If Proceeding with Max Subscription Headless (Despite Risks) - -#### Step 1: Generate OAuth Tokens - -```bash -# On development machine -claude setup-token - -# Save output securely -# Access token: sk-ant-oat01-... -# Refresh token: sk-ant-ort01-... (from ~/.claude/.credentials.json) -``` - -#### Step 2: Docker Container Setup - -**Dockerfile:** -```dockerfile -FROM python:3.12-slim - -# Install Claude Code -RUN pip install claude-code - -# Copy application -COPY ./app /app -WORKDIR /app - -# Environment variable will be injected at runtime -ENV CLAUDE_CODE_OAUTH_TOKEN="" - -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] -``` - -**docker-compose.yml:** -```yaml -version: '3.8' - -services: - fastapi: - build: . - environment: - # CRITICAL: Do NOT set ANTHROPIC_API_KEY - # It takes precedence over OAuth token - CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN} - volumes: - - ./app:/app - ports: - - "8000:8000" - -secrets: - claude_oauth_token: - file: ./secrets/claude_oauth_token.txt -``` - -#### Step 3: Token Refresh Mechanism - -**Python Implementation:** -```python -import httpx -import json -from pathlib import Path - -class ClaudeMaxAuth: - def __init__(self): - self.credentials_path = Path.home() / ".claude" / ".credentials.json" - self.access_token = None - self.refresh_token = None - self._load_credentials() - - def _load_credentials(self): - """Load tokens from credentials file or environment""" - if self.credentials_path.exists(): - with open(self.credentials_path) as f: - creds = json.load(f) - oauth = creds.get("claudeAiOauth", {}) - self.access_token = oauth.get("accessToken") - self.refresh_token = oauth.get("refreshToken") - - async def refresh_access_token(self): - """Refresh expired access token""" - async with httpx.AsyncClient() as client: - response = await client.post( - "https://console.anthropic.com/v1/oauth/token", - json={ - "grant_type": "refresh_token", - "refresh_token": self.refresh_token, - "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - } - ) - response.raise_for_status() - data = response.json() - self.access_token = data["access_token"] - # Update credentials file - self._save_credentials() - - def _save_credentials(self): - """Save updated tokens back to credentials file""" - # Implementation details... - pass -``` - -**Usage in FastAPI:** -```python -from fastapi import FastAPI, Depends -from claude_max import ClaudeMaxAuth - -app = FastAPI() -auth = ClaudeMaxAuth() - -async def get_claude_client(): - """Dependency that ensures fresh tokens""" - # Check if token needs refresh (implement logic) - if auth.token_expired(): - await auth.refresh_access_token() - return auth - -@app.post("/api/chat") -async def chat( - request: ChatRequest, - claude: ClaudeMaxAuth = Depends(get_claude_client) -): - # Use claude.access_token for requests - pass -``` - -#### Step 4: Monitoring and Fallback - -**Monitor subscription usage:** -```python -import subprocess - -def check_subscription_status(): - """Check Claude Code subscription status""" - result = subprocess.run( - ["claude", "status"], - capture_output=True, - text=True - ) - # Parse output to check remaining quota - return result.stdout -``` - -**Implement fallback to API:** -```python -async def make_claude_request(prompt: str): - """Try subscription first, fallback to API""" - try: - # Try subscription - response = await request_via_subscription(prompt) - return response - except QuotaExceededError: - # Fallback to API - logging.warning("Subscription quota exceeded, using API") - return await request_via_api(prompt) -``` - ---- - -## Critical Production Considerations - -### 1. Token Expiration Handling - -**Problem:** -Access tokens expire every 8-12 hours, causing service interruptions. - -**Solutions:** -- Implement automatic refresh before expiration -- Use refresh token rotation -- Monitor token validity and proactively refresh -- Have API key fallback for emergencies - -### 2. Rate Limit Management - -**Max Subscription Limits:** -- 900 messages / 5 hours (Max 20x plan) -- 200-800 prompts / 5 hours for Claude Code - -**Strategies:** -- Implement request queuing -- Track usage against 5-hour rolling window -- Return 429 errors when approaching limit -- Cache responses to reduce requests - -### 3. Shared Quota Between Web and Code - -**Critical Issue:** -> "Usage limits are shared between Claude Code and web claude.ai usage" - -**Implications:** -- If you use claude.ai in browser, it reduces FastAPI quota -- No way to reserve capacity for backend only -- Unpredictable availability during high web usage - -**Mitigation:** -- Use separate Claude account for backend -- Monitor total usage across all channels -- Set up alerts for high usage - -### 4. No Service Level Agreement (SLA) - -**Risk:** -- No guaranteed uptime for subscription-based access -- No support for programmatic usage issues -- Changes can break implementation without notice - -**Mitigation:** -- Don't use for mission-critical services -- Always have API fallback -- Monitor Anthropic announcements - ---- - -## Cost-Benefit Analysis - -### Scenario 1: Light Usage (< $200/month API cost) - -**Recommendation:** Use Anthropic API directly - -**Reasoning:** -- Simpler implementation -- Official support -- Commercial terms -- Predictable costs - -### Scenario 2: Heavy Usage ($200-$1000/month API cost) - -**Recommendation:** Max subscription for development, API for production - -**Reasoning:** -- Max subscription saves development costs -- API provides production reliability -- Total cost still lower than pure API -- Clear separation of concerns - -### Scenario 3: Very Heavy Usage (> $1000/month API cost) - -**Recommendation:** Contact Anthropic for Enterprise plan - -**Reasoning:** -- Custom pricing available -- Potentially subscription-style billing for automation -- Dedicated support -- SLA guarantees - ---- - -## Final Recommendations - -### ✅ Use Max Subscription Headless If: -- Personal project or internal tool -- Comfortable with policy gray area -- Can handle occasional service disruptions -- Have technical ability to implement token refresh -- Usage fits within Max limits ($200/month tier) - -### ❌ Do NOT Use Max Subscription Headless If: -- Production customer-facing service -- Enterprise/commercial application -- Need SLA guarantees -- Usage exceeds Max limits -- Uncomfortable with potential ToS violations - -### ✅ Recommended Approach: -1. **Development:** Use Max subscription ($200/month) -2. **Staging:** Use Max subscription with monitoring -3. **Production:** Use official Anthropic API with commercial terms -4. **Cost Optimization:** Evaluate usage patterns after 1 month - ---- - -## Authoritative Sources Summary - -### Official Documentation: -- [Using Claude Code with Pro or Max - Claude Help](https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan) -- [Docker Configure Claude Code](https://docs.docker.com/ai/sandboxes/claude-code/) -- [Claude Code Development Containers](https://code.claude.com/docs/en/devcontainer) - -### Community Resources: -- [GitHub - claude-code-sdk-docker](https://github.com/cabinlab/claude-code-sdk-docker) -- [Setup Container Authentication Guide](https://claude-did-this.com/claude-hub/getting-started/setup-container-guide) -- [GitHub - claude-token-refresh Tool](https://github.com/RavenStorm-bit/claude-token-refresh) - -### Technical Analysis: -- [How I Built claude_max - Substack](https://idsc2025.substack.com/p/how-i-built-claude_max-to-unlock) -- [Claude vs Claude API vs Claude Code](https://eval.16x.engineer/blog/claude-vs-claude-api-vs-claude-code) - -### GitHub Issues (Feature Requests & Bugs): -- [Issue #1454 - Machine to Machine Auth for Max](https://github.com/anthropics/claude-code/issues/1454) -- [Issue #7100 - Document Headless Authentication](https://github.com/anthropics/claude-code/issues/7100) -- [Issue #12447 - OAuth Token Expiration](https://github.com/anthropics/claude-code/issues/12447) -- [Issue #8938 - setup-token Not Enough](https://github.com/anthropics/claude-code/issues/8938) - ---- - -## Open Questions (Require Official Anthropic Response) - -1. **Is programmatic use of Max subscriptions permitted under consumer ToS?** - - Status: Unclear - - Action: Submit support ticket to Anthropic - -2. **Will Max subscriptions ever support official headless/server authentication?** - - Status: Feature request open (Issue #1454) - - Action: Monitor GitHub issues - -3. **What is the intended use case for `claude setup-token` command?** - - Status: Undocumented - - Action: Request official documentation - -4. **Are there Enterprise plans with subscription-style pricing for automation?** - - Status: Unknown - - Action: Contact Anthropic sales - ---- - -## Conclusion - -**YES, you CAN use Claude Max subscription programmatically in headless FastAPI backends** through OAuth token authentication, but this approach: - -1. ✅ **Works technically** - Multiple methods available -2. ⚠️ **Exists in policy gray area** - ToS unclear on automated usage -3. ⚠️ **Requires token refresh implementation** - Not zero-maintenance -4. ⚠️ **Has production limitations** - No SLA, shared quotas, expiring tokens -5. ✅ **Saves significant costs** - $200/month vs potentially thousands in API fees - -**Recommended path forward:** -1. Prototype with Max subscription to prove concept -2. Measure actual usage patterns -3. Calculate API costs for production scale -4. **If costs < $200/month:** Switch to official API -5. **If costs > $200/month:** Continue with Max but implement robust fallback -6. **If costs >> $1000/month:** Contact Anthropic for Enterprise pricing - -**Critical action item:** Submit support ticket to Anthropic asking explicitly if programmatic use of Max subscriptions for headless server environments is permitted under current ToS. - ---- - -**Research Completed:** 2025-12-14 -**Last Updated:** 2025-12-14 -**Next Review:** Monitor GitHub issues and Anthropic announcements monthly diff --git a/examples/minimal-production/backend/.env.example b/examples/minimal-production/backend/.env.example new file mode 100644 index 0000000..0e643f6 --- /dev/null +++ b/examples/minimal-production/backend/.env.example @@ -0,0 +1,22 @@ +# Application +APP_NAME="Minimal FastAPI Template" +ENVIRONMENT=development +DEBUG=false + +# Server +HOST=0.0.0.0 +PORT=8000 +RELOAD=true + +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/dbname +DB_POOL_SIZE=20 +DB_MAX_OVERFLOW=10 + +# JWT +SECRET_KEY=your-secret-key-min-32-characters-long +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# CORS +CORS_ORIGINS=["http://localhost:3420","http://localhost:8420"] diff --git a/examples/minimal-production/backend/Dockerfile b/examples/minimal-production/backend/Dockerfile new file mode 100644 index 0000000..1091caa --- /dev/null +++ b/examples/minimal-production/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ +RUN pip install --no-cache-dir -e ".[dev]" + +COPY . . + +ENV PYTHONPATH=/app/app +ENV PYTHONUNBUFFERED=1 + +CMD ["sh", "-c", "alembic upgrade head && uvicorn __main__:app --host 0.0.0.0 --port 8000"] diff --git a/examples/minimal-production/backend/README.md b/examples/minimal-production/backend/README.md new file mode 100644 index 0000000..947b706 --- /dev/null +++ b/examples/minimal-production/backend/README.md @@ -0,0 +1,92 @@ +# Minimal FastAPI Template + +A minimal production-ready FastAPI template with JWT authentication and PostgreSQL. + +## Features + +- FastAPI with async/await +- JWT access token authentication +- PostgreSQL with SQLAlchemy 2.0 (async) +- Alembic migrations +- Domain-driven structure +- Docker and docker-compose setup + +## Quick Start + +1. Copy `.env.example` to `.env` and update the values: +```bash +cp .env.example .env +``` + +2. Start the services: +```bash +docker-compose up -d +``` + +The API will be available at http://localhost:8000 + +API docs at http://localhost:8000/docs + +## Project Structure + +``` +backend/ +├── app/ +│ ├── auth/ # Authentication domain +│ ├── core/ # Core utilities and shared code +│ ├── user/ # User domain +│ ├── config.py # Application settings +│ ├── factory.py # FastAPI app factory +│ └── __main__.py # Entry point +├── alembic/ # Database migrations +├── pyproject.toml # Dependencies +├── Dockerfile +└── .env.example +``` + +## Development + +Install dependencies: +```bash +cd backend +pip install -e ".[dev]" +``` + +Run locally: +```bash +cd backend/app +python -m uvicorn __main__:app --reload +``` + +Create new migration: +```bash +cd backend +alembic revision --autogenerate -m "description" +``` + +Run migrations: +```bash +cd backend +alembic upgrade head +``` + +## API Endpoints + +- `POST /v1/users` - Register new user +- `POST /v1/auth/login` - Login (returns access token) +- `GET /v1/auth/me` - Get current user +- `POST /v1/auth/logout` - Logout (placeholder) +- `POST /v1/users/change-password` - Change password +- `GET /health` - Health check +- `GET /health/detailed` - Detailed health check + +## Extending + +This is a minimal starting point. You can add: +- Refresh tokens +- Email verification +- Password reset +- User roles +- Rate limiting (add Redis + slowapi) +- Structured logging (add structlog) +- More domains following the same pattern diff --git a/examples/minimal-production/backend/alembic.ini b/examples/minimal-production/backend/alembic.ini new file mode 100644 index 0000000..fc4ddbe --- /dev/null +++ b/examples/minimal-production/backend/alembic.ini @@ -0,0 +1,43 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/examples/minimal-production/backend/alembic/env.py b/examples/minimal-production/backend/alembic/env.py new file mode 100644 index 0000000..3cc55c2 --- /dev/null +++ b/examples/minimal-production/backend/alembic/env.py @@ -0,0 +1,115 @@ +""" +ⒸAngelaMos | 2025 +env.py +""" +import asyncio +import sys +from pathlib import Path +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "app")) + +from config import settings +from core.Base import Base +from user.User import User + + +config = context.config + + +def render_item(type_, obj, autogen_context): + """ + Custom renderer for alembic autogenerate + """ + import sqlalchemy as sa + + if isinstance(obj, sa.DateTime): + return "sa.DateTime(timezone=True)" + + return False + + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_url() -> str: + """ + Get database URL from settings + """ + return str(settings.DATABASE_URL) + + +def run_migrations_offline() -> None: + """ + Run migrations in 'offline' mode + """ + url = get_url() + context.configure( + url = url, + target_metadata = target_metadata, + literal_binds = True, + dialect_opts = {"paramstyle": "named"}, + compare_type = True, + compare_server_default = True, + render_item = render_item, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """ + Run migrations with connection + """ + context.configure( + connection = connection, + target_metadata = target_metadata, + compare_type = True, + compare_server_default = True, + render_item = render_item, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """ + Run migrations in async mode + """ + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_url() + + connectable = async_engine_from_config( + configuration, + prefix = "sqlalchemy.", + poolclass = pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """ + Run migrations in 'online' mode + """ + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/examples/minimal-production/backend/alembic/script.py.mako b/examples/minimal-production/backend/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/examples/minimal-production/backend/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/examples/minimal-production/backend/alembic/versions/20251215_000001_initial_schema.py b/examples/minimal-production/backend/alembic/versions/20251215_000001_initial_schema.py new file mode 100644 index 0000000..60068ad --- /dev/null +++ b/examples/minimal-production/backend/alembic/versions/20251215_000001_initial_schema.py @@ -0,0 +1,61 @@ +"""initial_schema + +Revision ID: 000001 +Revises: +Create Date: 2025-12-15 00:00:01.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '000001' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'users', + sa.Column('id', + sa.Uuid(), + nullable = False), + sa.Column('email', + sa.String(length = 320), + nullable = False), + sa.Column( + 'hashed_password', + sa.String(length = 1024), + nullable = False + ), + sa.Column( + 'is_active', + sa.Boolean(), + nullable = False, + server_default = sa.text('true') + ), + sa.Column( + 'created_at', + sa.DateTime(timezone = True), + server_default = sa.text('now()'), + nullable = False + ), + sa.Column( + 'updated_at', + sa.DateTime(timezone = True), + nullable = True + ), + sa.PrimaryKeyConstraint('id', + name = op.f('pk_users')), + sa.UniqueConstraint('email', + name = op.f('uq_users_email')) + ) + op.create_index(op.f('ix_email'), 'users', ['email'], unique = True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_email'), table_name = 'users') + op.drop_table('users') diff --git a/examples/minimal-production/backend/app/__main__.py b/examples/minimal-production/backend/app/__main__.py new file mode 100644 index 0000000..9fca783 --- /dev/null +++ b/examples/minimal-production/backend/app/__main__.py @@ -0,0 +1,19 @@ +""" +ⒸAngelaMos | 2025 +__main__.py +""" +import uvicorn + +from config import settings +from factory import create_app + + +app = create_app() + +if __name__ == "__main__": + uvicorn.run( + "__main__:app", + host = settings.HOST, + port = settings.PORT, + reload = settings.RELOAD, + ) diff --git a/examples/minimal-production/backend/app/auth/dependencies.py b/examples/minimal-production/backend/app/auth/dependencies.py new file mode 100644 index 0000000..76ff48b --- /dev/null +++ b/examples/minimal-production/backend/app/auth/dependencies.py @@ -0,0 +1,20 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from typing import Annotated +from fastapi import Depends + +from core.dependencies import DBSession +from .service import AuthService + + +def get_auth_service(db: DBSession) -> AuthService: + """ + Dependency for Auth service + """ + return AuthService(db) + + +AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] diff --git a/examples/minimal-production/backend/app/auth/routes.py b/examples/minimal-production/backend/app/auth/routes.py new file mode 100644 index 0000000..6797cc4 --- /dev/null +++ b/examples/minimal-production/backend/app/auth/routes.py @@ -0,0 +1,63 @@ +""" +ⒸAngelaMos | 2025 +routes.py +""" + +from typing import Annotated + +from fastapi import ( + APIRouter, + Depends, + status, +) +from fastapi.security import ( + OAuth2PasswordRequestForm, +) + +from core.dependencies import CurrentUser +from .schemas import TokenWithUserResponse +from user.schemas import UserResponse +from .dependencies import AuthServiceDep +from core.responses import AUTH_401 + + +router = APIRouter(prefix = "/auth", tags = ["auth"]) + + +@router.post( + "/login", + response_model = TokenWithUserResponse, + responses = {**AUTH_401} +) +async def login( + auth_service: AuthServiceDep, + form_data: Annotated[OAuth2PasswordRequestForm, + Depends()], +) -> TokenWithUserResponse: + """ + Login with email and password + """ + return await auth_service.login( + email = form_data.username, + password = form_data.password, + ) + + +@router.get("/me", response_model = UserResponse, responses = {**AUTH_401}) +async def get_current_user(current_user: CurrentUser) -> UserResponse: + """ + Get current authenticated user + """ + return UserResponse.model_validate(current_user) + + +@router.post( + "/logout", + status_code = status.HTTP_204_NO_CONTENT, + responses = {**AUTH_401} +) +async def logout(_: CurrentUser) -> None: + """ + Logout current session + """ + pass diff --git a/examples/minimal-production/backend/app/auth/schemas.py b/examples/minimal-production/backend/app/auth/schemas.py new file mode 100644 index 0000000..a52e723 --- /dev/null +++ b/examples/minimal-production/backend/app/auth/schemas.py @@ -0,0 +1,15 @@ +""" +ⒸAngelaMos | 2025 +schemas.py +""" + +from core.base_schema import BaseSchema +from user.schemas import UserResponse + + +class TokenWithUserResponse(BaseSchema): + """ + Login response with access token and user data + """ + access_token: str + user: UserResponse diff --git a/examples/minimal-production/backend/app/auth/service.py b/examples/minimal-production/backend/app/auth/service.py new file mode 100644 index 0000000..8257916 --- /dev/null +++ b/examples/minimal-production/backend/app/auth/service.py @@ -0,0 +1,82 @@ +""" +ⒸAngelaMos | 2025 +service.py +""" + +from sqlalchemy.ext.asyncio import ( + AsyncSession, +) + +from core.exceptions import ( + InvalidCredentials, +) +from core.security import ( + create_access_token, + verify_password_with_timing_safety, +) +from user.User import User +from user.repository import UserRepository +from .schemas import ( + TokenWithUserResponse, +) +from user.schemas import UserResponse + + +class AuthService: + """ + Business logic for authentication operations + """ + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def authenticate( + self, + email: str, + password: str, + ) -> tuple[str, + User]: + """ + Authenticate user and create access token + """ + user = await UserRepository.get_by_email(self.session, email) + hashed_password = user.hashed_password if user else None + + is_valid, new_hash = await verify_password_with_timing_safety( + password, hashed_password + ) + + if not is_valid or user is None: + raise InvalidCredentials() + + if not user.is_active: + raise InvalidCredentials() + + if new_hash: + await UserRepository.update_password( + self.session, + user, + new_hash + ) + + access_token = create_access_token(user.id) + + return access_token, user + + async def login( + self, + email: str, + password: str, + ) -> TokenWithUserResponse: + """ + Login and return token with user data + """ + access_token, user = await self.authenticate( + email, + password, + ) + + response = TokenWithUserResponse( + access_token = access_token, + user = UserResponse.model_validate(user), + ) + return response diff --git a/examples/minimal-production/backend/app/config.py b/examples/minimal-production/backend/app/config.py new file mode 100644 index 0000000..4597215 --- /dev/null +++ b/examples/minimal-production/backend/app/config.py @@ -0,0 +1,134 @@ +""" +ⒸAngelaMos | 2025 +config.py +""" + +from pathlib import Path +from typing import Literal +from functools import lru_cache + +from pydantic import ( + Field, + SecretStr, + PostgresDsn, + model_validator, +) +from pydantic_settings import ( + BaseSettings, + SettingsConfigDict, +) + +from core.constants import ( + API_PREFIX, + API_VERSION, + EMAIL_MAX_LENGTH, + PASSWORD_HASH_MAX_LENGTH, + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, +) +from core.enums import ( + Environment, + HealthStatus, + TokenType, +) + + +__all__ = [ + "API_PREFIX", + "API_VERSION", + "EMAIL_MAX_LENGTH", + "PASSWORD_HASH_MAX_LENGTH", + "PASSWORD_MAX_LENGTH", + "PASSWORD_MIN_LENGTH", + "Environment", + "HealthStatus", + "Settings", + "TokenType", + "get_settings", + "settings", +] + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +_ENV_FILE = _PROJECT_ROOT / ".env" + + +class Settings(BaseSettings): + """ + Application settings loaded from environment variables + """ + model_config = SettingsConfigDict( + env_file = _ENV_FILE, + env_file_encoding = "utf-8", + case_sensitive = False, + extra = "ignore", + ) + + APP_NAME: str = "Minimal FastAPI Template" + APP_VERSION: str = "1.0.0" + APP_SUMMARY: str = "Minimal FastAPI Backend Template" + APP_DESCRIPTION: str = "Simple async backend with JWT auth and PostgreSQL" + + ENVIRONMENT: Environment = Environment.DEVELOPMENT + DEBUG: bool = False + + HOST: str = "0.0.0.0" + PORT: int = 8000 + RELOAD: bool = True + + DATABASE_URL: PostgresDsn + DB_POOL_SIZE: int = Field(default = 20, ge = 5, le = 100) + DB_MAX_OVERFLOW: int = Field(default = 10, ge = 0, le = 50) + DB_POOL_TIMEOUT: int = Field(default = 30, ge = 10) + DB_POOL_RECYCLE: int = Field(default = 1800, ge = 300) + + SECRET_KEY: SecretStr = Field(..., min_length = 32) + JWT_ALGORITHM: Literal["HS256", "HS384", "HS512"] = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field( + default = 30, + ge = 5, + le = 120 + ) + + CORS_ORIGINS: list[str] = [ + "http://localhost", + "http://localhost:3420", + "http://localhost:8420", + ] + CORS_ALLOW_CREDENTIALS: bool = True + CORS_ALLOW_METHODS: list[str] = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS" + ] + CORS_ALLOW_HEADERS: list[str] = ["*"] + + PAGINATION_DEFAULT_SIZE: int = Field(default = 20, ge = 1, le = 100) + PAGINATION_MAX_SIZE: int = Field(default = 100, ge = 1, le = 500) + + @model_validator(mode = "after") + def validate_production_settings(self) -> "Settings": + """ + Enforce security constraints in production environment + """ + if self.ENVIRONMENT == Environment.PRODUCTION: + if self.DEBUG: + raise ValueError("DEBUG must be False in production") + if self.CORS_ORIGINS == ["*"]: + raise ValueError( + "CORS_ORIGINS cannot be ['*'] in production" + ) + return self + + +@lru_cache +def get_settings() -> Settings: + """ + Cached settings instance to avoid repeated env parsing + """ + return Settings() + + +settings = get_settings() diff --git a/examples/minimal-production/backend/app/core/Base.py b/examples/minimal-production/backend/app/core/Base.py new file mode 100644 index 0000000..deecde3 --- /dev/null +++ b/examples/minimal-production/backend/app/core/Base.py @@ -0,0 +1,63 @@ +""" +ⒸAngelaMos | 2025 +Base.py +""" + +from uuid import UUID +from datetime import UTC, datetime + +import uuid6 +from sqlalchemy.orm import ( + Mapped, + mapped_column, + DeclarativeBase, +) +from sqlalchemy import ( + DateTime, + MetaData, + func, +) +from sqlalchemy.ext.asyncio import AsyncAttrs + + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(AsyncAttrs, DeclarativeBase): + """ + Base class for all SQLAlchemy models + """ + metadata = MetaData(naming_convention = NAMING_CONVENTION) + + +class UUIDMixin: + """ + Mixin for UUID v7 primary key + """ + id: Mapped[UUID] = mapped_column( + primary_key = True, + default = uuid6.uuid7, + ) + + +class TimestampMixin: + """ + Mixin for created_at and updated_at timestamps + """ + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone = True), + default = lambda: datetime.now(UTC), + server_default = func.now(), + ) + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone = True), + default = None, + onupdate = lambda: datetime.now(UTC), + server_onupdate = func.now(), + ) diff --git a/examples/minimal-production/backend/app/core/base_repository.py b/examples/minimal-production/backend/app/core/base_repository.py new file mode 100644 index 0000000..030ea6a --- /dev/null +++ b/examples/minimal-production/backend/app/core/base_repository.py @@ -0,0 +1,106 @@ +""" +ⒸAngelaMos | 2025 +base_repository.py +""" + +from collections.abc import Sequence +from typing import ( + Any, + Generic, + TypeVar, +) +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from .Base import Base + + +ModelT = TypeVar("ModelT", bound = Base) + + +class BaseRepository(Generic[ModelT]): + """ + Generic repository with common CRUD operations + """ + model: type[ModelT] + + @classmethod + async def get_by_id( + cls, + session: AsyncSession, + id: UUID, + ) -> ModelT | None: + """ + Get a single record by ID + """ + return await session.get(cls.model, id) + + @classmethod + async def get_multi( + cls, + session: AsyncSession, + skip: int = 0, + limit: int = 100, + ) -> Sequence[ModelT]: + """ + Get multiple records with pagination + """ + result = await session.execute( + select(cls.model).offset(skip).limit(limit) + ) + return result.scalars().all() + + @classmethod + async def count(cls, session: AsyncSession) -> int: + """ + Count total records + """ + result = await session.execute( + select(func.count()).select_from(cls.model) + ) + return result.scalar_one() + + @classmethod + async def create( + cls, + session: AsyncSession, + **kwargs: Any, + ) -> ModelT: + """ + Create a new record + """ + instance = cls.model(**kwargs) + session.add(instance) + await session.flush() + await session.refresh(instance) + return instance + + @classmethod + async def update( + cls, + session: AsyncSession, + instance: ModelT, + **kwargs: Any, + ) -> ModelT: + """ + Update an existing record + """ + for key, value in kwargs.items(): + setattr(instance, key, value) + await session.flush() + await session.refresh(instance) + return instance + + @classmethod + async def delete( + cls, + session: AsyncSession, + instance: ModelT, + ) -> None: + """ + Delete a record + """ + await session.delete(instance) + await session.flush() diff --git a/examples/minimal-production/backend/app/core/base_schema.py b/examples/minimal-production/backend/app/core/base_schema.py new file mode 100644 index 0000000..9c26c0e --- /dev/null +++ b/examples/minimal-production/backend/app/core/base_schema.py @@ -0,0 +1,31 @@ +""" +ⒸAngelaMos | 2025 +base.py +""" + +from uuid import UUID +from datetime import datetime + +from pydantic import ( + BaseModel, + ConfigDict, +) + + +class BaseSchema(BaseModel): + """ + Base schema with common configuration + """ + model_config = ConfigDict( + from_attributes = True, + str_strip_whitespace = True, + ) + + +class BaseResponseSchema(BaseSchema): + """ + Base schema for API responses with common fields + """ + id: UUID + created_at: datetime + updated_at: datetime | None = None diff --git a/examples/minimal-production/backend/app/core/common_schemas.py b/examples/minimal-production/backend/app/core/common_schemas.py new file mode 100644 index 0000000..3abe927 --- /dev/null +++ b/examples/minimal-production/backend/app/core/common_schemas.py @@ -0,0 +1,33 @@ +""" +ⒸAngelaMos | 2025 +common_schemas.py +""" + +from config import HealthStatus +from .base_schema import BaseSchema + + +class HealthResponse(BaseSchema): + """ + Health check response + """ + status: HealthStatus + environment: str + version: str + + +class HealthDetailedResponse(HealthResponse): + """ + Detailed health check with database status + """ + database: HealthStatus + + +class AppInfoResponse(BaseSchema): + """ + Root endpoint response with API information + """ + name: str + version: str + environment: str + docs_url: str | None diff --git a/examples/minimal-production/backend/app/core/constants.py b/examples/minimal-production/backend/app/core/constants.py new file mode 100644 index 0000000..180b4c6 --- /dev/null +++ b/examples/minimal-production/backend/app/core/constants.py @@ -0,0 +1,12 @@ +""" +ⒸAngelaMos | 2025 +constants.py +""" + +EMAIL_MAX_LENGTH = 320 +PASSWORD_MIN_LENGTH = 8 +PASSWORD_MAX_LENGTH = 128 +PASSWORD_HASH_MAX_LENGTH = 1024 + +API_VERSION = "v1" +API_PREFIX = f"/{API_VERSION}" diff --git a/examples/minimal-production/backend/app/core/database.py b/examples/minimal-production/backend/app/core/database.py new file mode 100644 index 0000000..03ffeef --- /dev/null +++ b/examples/minimal-production/backend/app/core/database.py @@ -0,0 +1,160 @@ +""" +ⒸAngelaMos | 2025 +database.py +""" + +import contextlib +from collections.abc import ( + AsyncIterator, + Iterator, +) + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.engine.url import make_url +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + AsyncConnection, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import Session, sessionmaker + +from config import settings + + +class DatabaseSessionManager: + """ + Manages database connections and sessions for both sync and async contexts + """ + def __init__(self) -> None: + self._async_engine: AsyncEngine | None = None + self._sync_engine: Engine | None = None + self._async_sessionmaker: async_sessionmaker[AsyncSession + ] | None = None + self._sync_sessionmaker: sessionmaker[Session] | None = None + + def init(self, database_url: str) -> None: + """ + Initialize database engines and session factories + """ + base_url = make_url(database_url) + + async_url = base_url.set(drivername = "postgresql+asyncpg") + self._async_engine = create_async_engine( + async_url, + pool_size = settings.DB_POOL_SIZE, + max_overflow = settings.DB_MAX_OVERFLOW, + pool_timeout = settings.DB_POOL_TIMEOUT, + pool_recycle = settings.DB_POOL_RECYCLE, + pool_pre_ping = True, + echo = settings.DEBUG, + ) + self._async_sessionmaker = async_sessionmaker( + bind = self._async_engine, + class_ = AsyncSession, + autocommit = False, + autoflush = False, + expire_on_commit = False, + ) + + sync_url = base_url.set(drivername = "postgresql+psycopg2") + self._sync_engine = create_engine( + sync_url, + pool_size = settings.DB_POOL_SIZE, + max_overflow = settings.DB_MAX_OVERFLOW, + pool_timeout = settings.DB_POOL_TIMEOUT, + pool_recycle = settings.DB_POOL_RECYCLE, + pool_pre_ping = True, + echo = settings.DEBUG, + ) + self._sync_sessionmaker = sessionmaker( + bind = self._sync_engine, + autocommit = False, + autoflush = False, + expire_on_commit = False, + ) + + async def close(self) -> None: + """ + Dispose of all database connections + """ + if self._async_engine: + await self._async_engine.dispose() + self._async_engine = None + self._async_sessionmaker = None + + if self._sync_engine: + self._sync_engine.dispose() + self._sync_engine = None + self._sync_sessionmaker = None + + @contextlib.asynccontextmanager + async def session(self) -> AsyncIterator[AsyncSession]: + """ + Async context manager for database sessions + + Handles commit on success, rollback on exception + """ + if self._async_sessionmaker is None: + raise RuntimeError("DatabaseSessionManager is not initialized") + + session = self._async_sessionmaker() + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + @contextlib.asynccontextmanager + async def connect(self) -> AsyncIterator[AsyncConnection]: + """ + Async context manager for raw database connections + """ + if self._async_engine is None: + raise RuntimeError("DatabaseSessionManager is not initialized") + + async with self._async_engine.begin() as connection: + yield connection + + @property + def sync_engine(self) -> Engine: + """ + Sync engine for Alembic migrations + """ + if self._sync_engine is None: + raise RuntimeError("DatabaseSessionManager is not initialized") + return self._sync_engine + + @contextlib.contextmanager + def sync_session(self) -> Iterator[Session]: + """ + Sync context manager for migrations and CLI tools + """ + if self._sync_sessionmaker is None: + raise RuntimeError("DatabaseSessionManager is not initialized") + + session = self._sync_sessionmaker() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +sessionmanager = DatabaseSessionManager() + + +async def get_db_session() -> AsyncIterator[AsyncSession]: + """ + FastAPI dependency for database sessions + """ + async with sessionmanager.session() as session: + yield session diff --git a/examples/minimal-production/backend/app/core/dependencies.py b/examples/minimal-production/backend/app/core/dependencies.py new file mode 100644 index 0000000..38e4122 --- /dev/null +++ b/examples/minimal-production/backend/app/core/dependencies.py @@ -0,0 +1,76 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +import jwt +from fastapi import Depends +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from config import ( + API_PREFIX, + TokenType, +) +from .database import get_db_session +from .exceptions import ( + InactiveUser, + TokenError, + UserNotFound, +) +from user.User import User +from .security import decode_access_token +from user.repository import UserRepository + + +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl = f"{API_PREFIX}/auth/login", + auto_error = True, +) + +DBSession = Annotated[AsyncSession, Depends(get_db_session)] + + +async def get_current_user( + token: Annotated[str, + Depends(oauth2_scheme)], + db: DBSession, +) -> User: + """ + Validate access token and return current user + """ + try: + payload = decode_access_token(token) + except jwt.InvalidTokenError as e: + raise TokenError(message = str(e)) from e + + if payload.get("type") != TokenType.ACCESS.value: + raise TokenError(message = "Invalid token type") + + user_id = UUID(payload["sub"]) + user = await UserRepository.get_by_id(db, user_id) + + if user is None: + raise UserNotFound(identifier = str(user_id)) + + return user + + +async def get_current_active_user( + user: Annotated[User, + Depends(get_current_user)], +) -> User: + """ + Ensure user is active + """ + if not user.is_active: + raise InactiveUser() + return user + + +CurrentUser = Annotated["User", Depends(get_current_active_user)] diff --git a/examples/minimal-production/backend/app/core/enums.py b/examples/minimal-production/backend/app/core/enums.py new file mode 100644 index 0000000..e232c70 --- /dev/null +++ b/examples/minimal-production/backend/app/core/enums.py @@ -0,0 +1,31 @@ +""" +ⒸAngelaMos | 2025 +enums.py +""" + +from enum import Enum + + +class Environment(str, Enum): + """ + Application environment + """ + DEVELOPMENT = "development" + STAGING = "staging" + PRODUCTION = "production" + + +class TokenType(str, Enum): + """ + JWT token types + """ + ACCESS = "access" + + +class HealthStatus(str, Enum): + """ + Health check status values + """ + HEALTHY = "healthy" + UNHEALTHY = "unhealthy" + DEGRADED = "degraded" diff --git a/examples/minimal-production/backend/app/core/error_schemas.py b/examples/minimal-production/backend/app/core/error_schemas.py new file mode 100644 index 0000000..ec877f9 --- /dev/null +++ b/examples/minimal-production/backend/app/core/error_schemas.py @@ -0,0 +1,27 @@ +""" +ⒸAngelaMos | 2025 +error_schemas.py +""" + +from typing import ClassVar +from pydantic import Field, ConfigDict +from core.base_schema import BaseSchema + + +class ErrorDetail(BaseSchema): + """ + Standard error response format + """ + detail: str = Field(..., description = "Human readable error message") + type: str = Field(..., description = "Exception class name") + + model_config: ClassVar[ConfigDict] = ConfigDict( + json_schema_extra = { + "examples": [ + { + "detail": "User with id '123' not found", + "type": "UserNotFound" + } + ] + } + ) diff --git a/examples/minimal-production/backend/app/core/exceptions.py b/examples/minimal-production/backend/app/core/exceptions.py new file mode 100644 index 0000000..6669bbb --- /dev/null +++ b/examples/minimal-production/backend/app/core/exceptions.py @@ -0,0 +1,146 @@ +""" +ⒸAngelaMos | 2025 +exceptions.py +""" + +from typing import Any + + +class BaseAppException(Exception): + """ + Base exception for all application specific errors + """ + def __init__( + self, + message: str, + status_code: int = 500, + extra: dict[str, + Any] | None = None, + ) -> None: + self.message = message + self.status_code = status_code + self.extra = extra or {} + super().__init__(self.message) + + +class ResourceNotFound(BaseAppException): + """ + Raised when a requested resource does not exist + """ + def __init__( + self, + resource: str, + identifier: str | int, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + message = f"{resource} with id '{identifier}' not found", + status_code = 404, + extra = extra, + ) + self.resource = resource + self.identifier = identifier + + +class ConflictError(BaseAppException): + """ + Raised when an operation conflicts with existing state + """ + def __init__( + self, + message: str, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + message = message, + status_code = 409, + extra = extra + ) + + +class AuthenticationError(BaseAppException): + """ + Raised when authentication fails + """ + def __init__( + self, + message: str = "Authentication failed", + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + message = message, + status_code = 401, + extra = extra + ) + + +class TokenError(AuthenticationError): + """ + Raised for JWT token specific errors + """ + def __init__( + self, + message: str = "Invalid or expired token", + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__(message = message, extra = extra) + + +class UserNotFound(ResourceNotFound): + """ + Raised when a user is not found + """ + def __init__( + self, + identifier: str | int, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + resource = "User", + identifier = identifier, + extra = extra + ) + + +class EmailAlreadyExists(ConflictError): + """ + Raised when attempting to register with an existing email + """ + def __init__( + self, + email: str, + extra: dict[str, + Any] | None = None + ) -> None: + super().__init__( + message = f"Email '{email}' is already registered", + extra = extra, + ) + self.email = email + + +class InvalidCredentials(AuthenticationError): + """ + Raised when login credentials are invalid + """ + def __init__(self, extra: dict[str, Any] | None = None) -> None: + super().__init__( + message = "Invalid email or password", + extra = extra + ) + + +class InactiveUser(AuthenticationError): + """ + Raised when an inactive user attempts to authenticate + """ + def __init__(self, extra: dict[str, Any] | None = None) -> None: + super().__init__( + message = "User account is inactive", + extra = extra + ) diff --git a/examples/minimal-production/backend/app/core/health_routes.py b/examples/minimal-production/backend/app/core/health_routes.py new file mode 100644 index 0000000..ad19718 --- /dev/null +++ b/examples/minimal-production/backend/app/core/health_routes.py @@ -0,0 +1,67 @@ +""" +ⒸAngelaMos | 2025 +health_routes.py +""" + +from fastapi import ( + APIRouter, + status, +) +from sqlalchemy import text + +from config import ( + settings, + HealthStatus, +) +from .common_schemas import ( + HealthResponse, + HealthDetailedResponse, +) +from .database import sessionmanager + + +router = APIRouter(tags = ["health"]) + + +@router.get( + "/health", + response_model = HealthResponse, + status_code = status.HTTP_200_OK, +) +async def health_check() -> HealthResponse: + """ + Basic health check + """ + return HealthResponse( + status = HealthStatus.HEALTHY, + environment = settings.ENVIRONMENT.value, + version = settings.APP_VERSION, + ) + + +@router.get( + "/health/detailed", + response_model = HealthDetailedResponse, + status_code = status.HTTP_200_OK, +) +async def health_check_detailed() -> HealthDetailedResponse: + """ + Detailed health check including database connectivity + """ + db_status = HealthStatus.UNHEALTHY + + try: + async with sessionmanager.connect() as conn: + await conn.execute(text("SELECT 1")) + db_status = HealthStatus.HEALTHY + except Exception: + db_status = HealthStatus.UNHEALTHY + + overall = HealthStatus.HEALTHY if db_status == HealthStatus.HEALTHY else HealthStatus.DEGRADED + + return HealthDetailedResponse( + status = overall, + environment = settings.ENVIRONMENT.value, + version = settings.APP_VERSION, + database = db_status, + ) diff --git a/examples/minimal-production/backend/app/core/responses.py b/examples/minimal-production/backend/app/core/responses.py new file mode 100644 index 0000000..04b0172 --- /dev/null +++ b/examples/minimal-production/backend/app/core/responses.py @@ -0,0 +1,36 @@ +""" +ⒸAngelaMos | 2025 +responses.py +""" + +from typing import Any + +from .error_schemas import ErrorDetail + + +AUTH_401: dict[int | str, + dict[str, + Any]] = { + 401: { + "model": ErrorDetail, + "description": "Authentication failed" + }, + } + +NOT_FOUND_404: dict[int | str, + dict[str, + Any]] = { + 404: { + "model": ErrorDetail, + "description": "Resource not found" + }, + } + +CONFLICT_409: dict[int | str, + dict[str, + Any]] = { + 409: { + "model": ErrorDetail, + "description": "Resource conflict" + }, + } diff --git a/examples/minimal-production/backend/app/core/security.py b/examples/minimal-production/backend/app/core/security.py new file mode 100644 index 0000000..0957e31 --- /dev/null +++ b/examples/minimal-production/backend/app/core/security.py @@ -0,0 +1,111 @@ +""" +ⒸAngelaMos | 2025 +security.py +""" + +import asyncio +from datetime import ( + UTC, + datetime, + timedelta, +) +from typing import Any +from uuid import UUID + +import jwt +from pwdlib import PasswordHash + +from config import ( + settings, + TokenType, +) + + +password_hasher = PasswordHash.recommended() + + +async def hash_password(password: str) -> str: + """ + Hash password using Argon2id + """ + return await asyncio.to_thread(password_hasher.hash, password) + + +async def verify_password(plain_password: str, + hashed_password: str) -> tuple[bool, + str | None]: + """ + Verify password and check if rehash is needed + """ + try: + return await asyncio.to_thread( + password_hasher.verify_and_update, + plain_password, + hashed_password + ) + except Exception: + return False, None + + +DUMMY_HASH = password_hasher.hash( + "dummy_password_for_timing_attack_prevention" +) + + +async def verify_password_with_timing_safety( + plain_password: str, + hashed_password: str | None, +) -> tuple[bool, + str | None]: + """ + Verify password with constant time behavior to prevent user enumeration + """ + if hashed_password is None: + await asyncio.to_thread( + password_hasher.verify, + plain_password, + DUMMY_HASH + ) + return False, None + return await verify_password(plain_password, hashed_password) + + +def create_access_token( + user_id: UUID, + extra_claims: dict[str, + Any] | None = None, +) -> str: + """ + Create a short lived access token + """ + now = datetime.now(UTC) + payload = { + "sub": str(user_id), + "type": TokenType.ACCESS.value, + "iat": now, + "exp": + now + timedelta(minutes = settings.ACCESS_TOKEN_EXPIRE_MINUTES), + } + if extra_claims: + payload.update(extra_claims) + + return jwt.encode( + payload, + settings.SECRET_KEY.get_secret_value(), + algorithm = settings.JWT_ALGORITHM, + ) + + +def decode_access_token(token: str) -> dict[str, Any]: + """ + Decode and validate an access token + """ + return jwt.decode( + token, + settings.SECRET_KEY.get_secret_value(), + algorithms = [settings.JWT_ALGORITHM], + options = {"require": ["exp", + "sub", + "iat", + "type"]}, + ) diff --git a/examples/minimal-production/backend/app/factory.py b/examples/minimal-production/backend/app/factory.py new file mode 100644 index 0000000..8f17a52 --- /dev/null +++ b/examples/minimal-production/backend/app/factory.py @@ -0,0 +1,104 @@ +""" +ⒸAngelaMos | 2025 +factory.py +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from config import settings, Environment, API_PREFIX +from core.database import sessionmanager +from core.exceptions import BaseAppException +from core.common_schemas import AppInfoResponse +from core.health_routes import router as health_router +from user.routes import router as user_router +from auth.routes import router as auth_router + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """ + Application lifespan handler for startup and shutdown + """ + sessionmanager.init(str(settings.DATABASE_URL)) + yield + await sessionmanager.close() + + +OPENAPI_TAGS = [ + { + "name": "root", + "description": "API information" + }, + { + "name": "health", + "description": "Health check endpoints" + }, + { + "name": "auth", + "description": "Authentication" + }, + { + "name": "users", + "description": "User operations" + }, +] + + +def create_app() -> FastAPI: + """ + Application factory + """ + is_production = settings.ENVIRONMENT == Environment.PRODUCTION + + app = FastAPI( + title = settings.APP_NAME, + summary = settings.APP_SUMMARY, + description = settings.APP_DESCRIPTION, + version = settings.APP_VERSION, + openapi_tags = OPENAPI_TAGS, + lifespan = lifespan, + openapi_url = None if is_production else "/openapi.json", + docs_url = None if is_production else "/docs", + redoc_url = None if is_production else "/redoc", + ) + + app.add_middleware( + CORSMiddleware, + allow_origins = settings.CORS_ORIGINS, + allow_credentials = settings.CORS_ALLOW_CREDENTIALS, + allow_methods = settings.CORS_ALLOW_METHODS, + allow_headers = settings.CORS_ALLOW_HEADERS, + ) + + @app.exception_handler(BaseAppException) + async def app_exception_handler( + request: Request, + exc: BaseAppException, + ) -> JSONResponse: + return JSONResponse( + status_code = exc.status_code, + content = { + "detail": exc.message, + "type": exc.__class__.__name__, + }, + ) + + @app.get("/", response_model = AppInfoResponse, tags = ["root"]) + async def root() -> AppInfoResponse: + return AppInfoResponse( + name = settings.APP_NAME, + version = settings.APP_VERSION, + environment = settings.ENVIRONMENT.value, + docs_url = None if is_production else "/docs", + ) + + app.include_router(health_router) + app.include_router(auth_router, prefix = API_PREFIX) + app.include_router(user_router, prefix = API_PREFIX) + + return app diff --git a/examples/minimal-production/backend/app/user/User.py b/examples/minimal-production/backend/app/user/User.py new file mode 100644 index 0000000..b697a06 --- /dev/null +++ b/examples/minimal-production/backend/app/user/User.py @@ -0,0 +1,37 @@ +""" +ⒸAngelaMos | 2025 +User.py +""" + +from sqlalchemy import String +from sqlalchemy.orm import ( + Mapped, + mapped_column, +) + +from config import ( + EMAIL_MAX_LENGTH, + PASSWORD_HASH_MAX_LENGTH, +) +from core.Base import ( + Base, + TimestampMixin, + UUIDMixin, +) + + +class User(Base, UUIDMixin, TimestampMixin): + """ + User account model + """ + __tablename__ = "users" + + email: Mapped[str] = mapped_column( + String(EMAIL_MAX_LENGTH), + unique = True, + index = True, + ) + hashed_password: Mapped[str] = mapped_column( + String(PASSWORD_HASH_MAX_LENGTH) + ) + is_active: Mapped[bool] = mapped_column(default = True) diff --git a/examples/minimal-production/backend/app/user/dependencies.py b/examples/minimal-production/backend/app/user/dependencies.py new file mode 100644 index 0000000..3eb0fab --- /dev/null +++ b/examples/minimal-production/backend/app/user/dependencies.py @@ -0,0 +1,20 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from typing import Annotated +from fastapi import Depends + +from core.dependencies import DBSession +from .service import UserService + + +def get_user_service(db: DBSession) -> UserService: + """ + Dependency for User service + """ + return UserService(db) + + +UserServiceDep = Annotated[UserService, Depends(get_user_service)] diff --git a/examples/minimal-production/backend/app/user/repository.py b/examples/minimal-production/backend/app/user/repository.py new file mode 100644 index 0000000..d551f4a --- /dev/null +++ b/examples/minimal-production/backend/app/user/repository.py @@ -0,0 +1,91 @@ +""" +ⒸAngelaMos | 2025 +repository.py +""" +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from .User import User +from core.base_repository import BaseRepository + + +class UserRepository(BaseRepository[User]): + """ + Repository for User model database operations + """ + model = User + + @classmethod + async def get_by_email( + cls, + session: AsyncSession, + email: str, + ) -> User | None: + """ + Get user by email address + """ + result = await session.execute( + select(User).where(User.email == email) + ) + return result.scalars().first() + + @classmethod + async def get_by_id( + cls, + session: AsyncSession, + id: UUID, + ) -> User | None: + """ + Get user by ID + """ + return await session.get(User, id) + + @classmethod + async def email_exists( + cls, + session: AsyncSession, + email: str, + ) -> bool: + """ + Check if email is already registered + """ + result = await session.execute( + select(User.id).where(User.email == email) + ) + return result.scalars().first() is not None + + @classmethod + async def create_user( + cls, + session: AsyncSession, + email: str, + hashed_password: str, + ) -> User: + """ + Create a new user + """ + user = User( + email = email, + hashed_password = hashed_password, + ) + session.add(user) + await session.flush() + await session.refresh(user) + return user + + @classmethod + async def update_password( + cls, + session: AsyncSession, + user: User, + hashed_password: str, + ) -> User: + """ + Update user password + """ + user.hashed_password = hashed_password + await session.flush() + await session.refresh(user) + return user diff --git a/examples/minimal-production/backend/app/user/routes.py b/examples/minimal-production/backend/app/user/routes.py new file mode 100644 index 0000000..7089472 --- /dev/null +++ b/examples/minimal-production/backend/app/user/routes.py @@ -0,0 +1,82 @@ +""" +ⒸAngelaMos | 2025 +routes.py +""" + +from uuid import UUID + +from fastapi import ( + APIRouter, + status, +) + +from core.dependencies import CurrentUser +from core.responses import ( + AUTH_401, + CONFLICT_409, + NOT_FOUND_404, +) +from .schemas import ( + PasswordChange, + UserCreate, + UserResponse, +) +from .dependencies import UserServiceDep + + +router = APIRouter(prefix = "/users", tags = ["users"]) + + +@router.post( + "", + response_model = UserResponse, + status_code = status.HTTP_201_CREATED, + responses = {**CONFLICT_409}, +) +async def create_user( + user_service: UserServiceDep, + user_data: UserCreate, +) -> UserResponse: + """ + Register a new user + """ + return await user_service.create_user(user_data) + + +@router.get( + "/{user_id}", + response_model = UserResponse, + responses = { + **AUTH_401, + **NOT_FOUND_404 + }, +) +async def get_user( + user_service: UserServiceDep, + user_id: UUID, + _: CurrentUser, +) -> UserResponse: + """ + Get user by ID + """ + return await user_service.get_user_by_id(user_id) + + +@router.post( + "/change-password", + status_code = status.HTTP_204_NO_CONTENT, + responses = {**AUTH_401} +) +async def change_password( + user_service: UserServiceDep, + current_user: CurrentUser, + data: PasswordChange, +) -> None: + """ + Change current user password + """ + await user_service.change_password( + current_user, + data.current_password, + data.new_password, + ) diff --git a/examples/minimal-production/backend/app/user/schemas.py b/examples/minimal-production/backend/app/user/schemas.py new file mode 100644 index 0000000..3623c69 --- /dev/null +++ b/examples/minimal-production/backend/app/user/schemas.py @@ -0,0 +1,77 @@ +""" +ⒸAngelaMos | 2025 +schemas.py +""" + +from pydantic import ( + Field, + EmailStr, + field_validator, +) + +from config import ( + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, +) +from core.base_schema import ( + BaseSchema, + BaseResponseSchema, +) + + +class UserCreate(BaseSchema): + """ + Schema for user registration + """ + email: EmailStr + password: str = Field( + min_length = PASSWORD_MIN_LENGTH, + max_length = PASSWORD_MAX_LENGTH + ) + + @field_validator("password") + @classmethod + def validate_password_strength(cls, v: str) -> str: + """ + Ensure password has minimum complexity + """ + if not any(c.isupper() for c in v): + raise ValueError( + "Password must contain at least one uppercase letter" + ) + if not any(c.isdigit() for c in v): + raise ValueError("Password must contain at least one digit") + return v + + +class UserResponse(BaseResponseSchema): + """ + Schema for user API responses + """ + email: EmailStr + is_active: bool + + +class PasswordChange(BaseSchema): + """ + Schema for password change + """ + current_password: str + new_password: str = Field( + min_length = PASSWORD_MIN_LENGTH, + max_length = PASSWORD_MAX_LENGTH + ) + + @field_validator("new_password") + @classmethod + def validate_password_strength(cls, v: str) -> str: + """ + Ensure password has minimum complexity + """ + if not any(c.isupper() for c in v): + raise ValueError( + "Password must contain at least one uppercase letter" + ) + if not any(c.isdigit() for c in v): + raise ValueError("Password must contain at least one digit") + return v diff --git a/examples/minimal-production/backend/app/user/service.py b/examples/minimal-production/backend/app/user/service.py new file mode 100644 index 0000000..682be9d --- /dev/null +++ b/examples/minimal-production/backend/app/user/service.py @@ -0,0 +1,94 @@ +""" +ⒸAngelaMos | 2025 +service.py +""" + +from uuid import UUID +from sqlalchemy.ext.asyncio import ( + AsyncSession, +) + +from core.exceptions import ( + EmailAlreadyExists, + InvalidCredentials, + UserNotFound, +) +from core.security import ( + hash_password, + verify_password, +) +from .schemas import ( + UserCreate, + UserResponse, +) +from .User import User +from .repository import UserRepository + + +class UserService: + """ + Business logic for user operations + """ + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def create_user( + self, + user_data: UserCreate, + ) -> UserResponse: + """ + Register a new user + """ + if await UserRepository.email_exists(self.session, + user_data.email): + raise EmailAlreadyExists(user_data.email) + + hashed = await hash_password(user_data.password) + user = await UserRepository.create_user( + self.session, + email = user_data.email, + hashed_password = hashed, + ) + return UserResponse.model_validate(user) + + async def get_user_by_id( + self, + user_id: UUID, + ) -> UserResponse: + """ + Get user by ID + """ + user = await UserRepository.get_by_id(self.session, user_id) + if not user: + raise UserNotFound(str(user_id)) + return UserResponse.model_validate(user) + + async def change_password( + self, + user: User, + current_password: str, + new_password: str, + ) -> None: + """ + Change user password + """ + is_valid, _ = await verify_password(current_password, user.hashed_password) + if not is_valid: + raise InvalidCredentials() + + hashed = await hash_password(new_password) + await UserRepository.update_password(self.session, user, hashed) + + async def deactivate_user( + self, + user: User, + ) -> UserResponse: + """ + Deactivate user account + """ + updated = await UserRepository.update( + self.session, + user, + is_active = False + ) + return UserResponse.model_validate(updated) diff --git a/examples/minimal-production/backend/pyproject.toml b/examples/minimal-production/backend/pyproject.toml new file mode 100644 index 0000000..066ffc3 --- /dev/null +++ b/examples/minimal-production/backend/pyproject.toml @@ -0,0 +1,57 @@ +[project] +name = "minimal-fastapi-template" +version = "1.0.0" +description = "Minimal production FastAPI template with async SQLAlchemy and JWT auth" +requires-python = ">=3.12" + +dependencies = [ + "fastapi[standard]>=0.123.0,<1.0.0", + "pydantic>=2.12.5,<3.0.0", + "pydantic-settings>=2.12.0,<3.0.0", + "psycopg2-binary>=2.9.11", + "sqlalchemy>=2.0.44,<3.0.0", + "alembic>=1.17.0,<2.0.0", + "asyncpg>=0.31.0,<1.0.0", + "python-multipart>=0.0.20", + "pyjwt>=2.10.0", + "pwdlib[argon2]>=0.3.0", + "uuid6>=2025.0.1", + "gunicorn>=23.0.0", + "uvicorn[standard]>=0.38.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "httpx>=0.28.1", + "aiosqlite>=0.21.0", + "ruff>=0.14.8", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +target-version = "py312" +line-length = 88 +src = ["app"] +exclude = ["alembic"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long + "B008", # function call in default argument (FastAPI Depends) +] diff --git a/examples/minimal-production/docker-compose.yml b/examples/minimal-production/docker-compose.yml new file mode 100644 index 0000000..8c62715 --- /dev/null +++ b/examples/minimal-production/docker-compose.yml @@ -0,0 +1,42 @@ +# ========================================= +# AngelaMos | 2025 +# ========================================= + +services: + db: + image: postgres:18-alpine + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: minimal_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql://user:password@db:5432/minimal_db + SECRET_KEY: dev-secret-key-change-in-production-min32chars + ENVIRONMENT: development + DEBUG: "true" + RELOAD: "true" + volumes: + - ./backend:/app + command: sh -c "alembic upgrade head && uvicorn __main__:app --host 0.0.0.0 --port 8000 --reload" + +volumes: + postgres_data: diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..61cb0c2 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.vite # Editor directories and files .vscode/* diff --git a/frontend/index.html b/frontend/index.html index 130ce76..047ef6a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -26,7 +26,7 @@ /> Full Stack Template =18'} + core-js@1.2.7: + resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -1112,6 +1125,17 @@ packages: peerDependencies: react: '>=16.13.1' + react-icon@1.0.0: + resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==} + peerDependencies: + babel-runtime: ^5.3.3 + react: '>=0.12.0' + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -1844,6 +1868,10 @@ snapshots: transitivePeerDependencies: - debug + babel-runtime@5.8.38: + dependencies: + core-js: 1.2.7 + balanced-match@2.0.0: {} baseline-browser-mapping@2.9.5: {} @@ -1897,6 +1925,8 @@ snapshots: cookie@1.1.1: {} + core-js@1.2.7: {} + cosmiconfig@9.0.0(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -2293,6 +2323,15 @@ snapshots: '@babel/runtime': 7.28.4 react: 19.2.1 + react-icon@1.0.0(babel-runtime@5.8.38)(react@19.2.1): + dependencies: + babel-runtime: 5.8.38 + react: 19.2.1 + + react-icons@5.5.0(react@19.2.1): + dependencies: + react: 19.2.1 + react-refresh@0.18.0: {} react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): diff --git a/frontend/public/assets/android-chrome-192x192.png b/frontend/public/assets/android-chrome-192x192.png index a3757a1..01d0a08 100644 Binary files a/frontend/public/assets/android-chrome-192x192.png and b/frontend/public/assets/android-chrome-192x192.png differ diff --git a/frontend/public/assets/android-chrome-512x512.png b/frontend/public/assets/android-chrome-512x512.png index e448650..3ea8cdf 100644 Binary files a/frontend/public/assets/android-chrome-512x512.png and b/frontend/public/assets/android-chrome-512x512.png differ diff --git a/frontend/public/assets/apple-touch-icon.png b/frontend/public/assets/apple-touch-icon.png index a058f26..ec126db 100644 Binary files a/frontend/public/assets/apple-touch-icon.png and b/frontend/public/assets/apple-touch-icon.png differ diff --git a/frontend/public/assets/favicon-16x16.png b/frontend/public/assets/favicon-16x16.png index d55aa3e..75b6290 100644 Binary files a/frontend/public/assets/favicon-16x16.png and b/frontend/public/assets/favicon-16x16.png differ diff --git a/frontend/public/assets/favicon-32x32.png b/frontend/public/assets/favicon-32x32.png index 2487a8d..338e90d 100644 Binary files a/frontend/public/assets/favicon-32x32.png and b/frontend/public/assets/favicon-32x32.png differ diff --git a/frontend/public/assets/favicon.ico b/frontend/public/assets/favicon.ico index c218155..cf61746 100644 Binary files a/frontend/public/assets/favicon.ico and b/frontend/public/assets/favicon.ico differ diff --git a/frontend/public/assets/site.webmanifest b/frontend/public/assets/site.webmanifest index 640d50d..45dc8a2 100644 --- a/frontend/public/assets/site.webmanifest +++ b/frontend/public/assets/site.webmanifest @@ -1 +1 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#000000","display":"standalone"} +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ef4c938..77f8c36 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,18 @@ export default function App(): React.ReactElement {
- +
diff --git a/frontend/src/api/hooks/useAdmin.ts b/frontend/src/api/hooks/useAdmin.ts index 2d5a5b2..b339b0d 100644 --- a/frontend/src/api/hooks/useAdmin.ts +++ b/frontend/src/api/hooks/useAdmin.ts @@ -124,8 +124,8 @@ export const useAdminCreateUser = (): UseMutationResult< return useMutation({ mutationFn: performAdminCreateUser, - onSuccess: (): void => { - void queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) + onSuccess: async (): Promise => { + await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) toast.success(USER_SUCCESS_MESSAGES.CREATED) }, @@ -172,10 +172,13 @@ export const useAdminUpdateUser = (): UseMutationResult< return useMutation({ mutationFn: performAdminUpdateUser, - onSuccess: (data: UserResponse, variables: AdminUpdateUserParams): void => { + onSuccess: async ( + data: UserResponse, + variables: AdminUpdateUserParams + ): Promise => { queryClient.setQueryData(adminQueries.users.byId(variables.id), data) - void queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) + await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) toast.success(USER_SUCCESS_MESSAGES.UPDATED) }, @@ -198,10 +201,10 @@ export const useAdminDeleteUser = (): UseMutationResult => return useMutation({ mutationFn: performAdminDeleteUser, - onSuccess: (_, id: string): void => { + onSuccess: async (_, id: string): Promise => { queryClient.removeQueries({ queryKey: adminQueries.users.byId(id) }) - void queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) + await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) toast.success(USER_SUCCESS_MESSAGES.DELETED) }, diff --git a/frontend/src/api/types/auth.types.ts b/frontend/src/api/types/auth.types.ts index c79d015..bf1ff76 100644 --- a/frontend/src/api/types/auth.types.ts +++ b/frontend/src/api/types/auth.types.ts @@ -6,16 +6,18 @@ import { z } from 'zod' import { PASSWORD_CONSTRAINTS } from '@/config' -export enum UserRole { - UNKNOWN = 'unknown', - USER = 'user', - ADMIN = 'admin', -} +export const UserRole = { + UNKNOWN: 'unknown', + USER: 'user', + ADMIN: 'admin', +} as const + +export type UserRole = (typeof UserRole)[keyof typeof UserRole] export const userResponseSchema = z.object({ id: z.string().uuid(), created_at: z.string().datetime(), - updated_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), email: z.string().email(), full_name: z.string().nullable(), is_active: z.boolean(), @@ -106,12 +108,12 @@ export const isValidLogoutAllResponse = ( } export class AuthResponseError extends Error { - constructor( - message: string, - public readonly endpoint?: string - ) { + readonly endpoint?: string + + constructor(message: string, endpoint?: string) { super(message) this.name = 'AuthResponseError' + this.endpoint = endpoint Object.setPrototypeOf(this, AuthResponseError.prototype) } } diff --git a/frontend/src/api/types/user.types.ts b/frontend/src/api/types/user.types.ts index 1c6a498..2fd3623 100644 --- a/frontend/src/api/types/user.types.ts +++ b/frontend/src/api/types/user.types.ts @@ -92,12 +92,12 @@ export const isValidAdminUserCreateRequest = ( } export class UserResponseError extends Error { - constructor( - message: string, - public readonly endpoint?: string - ) { + readonly endpoint?: string + + constructor(message: string, endpoint?: string) { super(message) this.name = 'UserResponseError' + this.endpoint = endpoint Object.setPrototypeOf(this, UserResponseError.prototype) } } diff --git a/frontend/src/components/index.tsx b/frontend/src/components/index.tsx new file mode 100644 index 0000000..6b0a732 --- /dev/null +++ b/frontend/src/components/index.tsx @@ -0,0 +1,4 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 37d8e99..f175b96 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -2,29 +2,30 @@ // © AngelaMos | 2025 // config.ts // =================== +const API_VERSION = 'v1' export const API_ENDPOINTS = { AUTH: { - LOGIN: '/auth/login', - REFRESH: '/auth/refresh', - LOGOUT: '/auth/logout', - LOGOUT_ALL: '/auth/logout-all', - ME: '/auth/me', - CHANGE_PASSWORD: '/auth/change-password', + LOGIN: `/${API_VERSION}/auth/login`, + REFRESH: `/${API_VERSION}/auth/refresh`, + LOGOUT: `/${API_VERSION}/auth/logout`, + LOGOUT_ALL: `/${API_VERSION}/auth/logout-all`, + ME: `/${API_VERSION}/auth/me`, + CHANGE_PASSWORD: `/${API_VERSION}/auth/change-password`, }, USERS: { - BASE: '/users', - BY_ID: (id: string) => `/users/${id}`, - ME: '/users/me', - REGISTER: '/users', + BASE: `/${API_VERSION}/users`, + BY_ID: (id: string) => `/${API_VERSION}/users/${id}`, + ME: `/${API_VERSION}/users/me`, + REGISTER: `/${API_VERSION}/users`, }, ADMIN: { USERS: { - LIST: '/admin/users', - CREATE: '/admin/users', - BY_ID: (id: string) => `/admin/users/${id}`, - UPDATE: (id: string) => `/admin/users/${id}`, - DELETE: (id: string) => `/admin/users/${id}`, + LIST: `/${API_VERSION}/admin/users`, + CREATE: `/${API_VERSION}/admin/users`, + BY_ID: (id: string) => `/${API_VERSION}/admin/users/${id}`, + UPDATE: (id: string) => `/${API_VERSION}/admin/users/${id}`, + DELETE: (id: string) => `/${API_VERSION}/admin/users/${id}`, }, }, } as const diff --git a/frontend/src/core/api/errors.ts b/frontend/src/core/api/errors.ts index 33ee936..fde5ec6 100644 --- a/frontend/src/core/api/errors.ts +++ b/frontend/src/core/api/errors.ts @@ -5,27 +5,36 @@ import type { AxiosError } from 'axios' -export enum ApiErrorCode { - NETWORK_ERROR = 'NETWORK_ERROR', - VALIDATION_ERROR = 'VALIDATION_ERROR', - AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR', - AUTHORIZATION_ERROR = 'AUTHORIZATION_ERROR', - NOT_FOUND = 'NOT_FOUND', - CONFLICT = 'CONFLICT', - RATE_LIMITED = 'RATE_LIMITED', - SERVER_ERROR = 'SERVER_ERROR', - UNKNOWN_ERROR = 'UNKNOWN_ERROR', -} +export const ApiErrorCode = { + NETWORK_ERROR: 'NETWORK_ERROR', + VALIDATION_ERROR: 'VALIDATION_ERROR', + AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR', + AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + RATE_LIMITED: 'RATE_LIMITED', + SERVER_ERROR: 'SERVER_ERROR', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', +} as const + +export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode] export class ApiError extends Error { + readonly code: ApiErrorCode + readonly statusCode: number + readonly details?: Record + constructor( message: string, - public readonly code: ApiErrorCode, - public readonly statusCode: number, - public readonly details?: Record + code: ApiErrorCode, + statusCode: number, + details?: Record ) { super(message) this.name = 'ApiError' + this.code = code + this.statusCode = statusCode + this.details = details } getUserMessage(): string { @@ -56,14 +65,13 @@ interface ApiErrorResponse { message?: string } -export function transformAxiosError( - error: AxiosError -): ApiError { +export function transformAxiosError(error: AxiosError): ApiError { if (!error.response) { return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0) } - const { status, data } = error.response + const { status } = error.response + const data = error.response.data as ApiErrorResponse | undefined let message = 'An error occurred' let details: Record | undefined diff --git a/frontend/src/core/app/routers.tsx b/frontend/src/core/app/routers.tsx index a86926a..cb2473c 100644 --- a/frontend/src/core/app/routers.tsx +++ b/frontend/src/core/app/routers.tsx @@ -10,23 +10,31 @@ import { ProtectedRoute } from './protected-route' import { Shell } from './shell' const routes: RouteObject[] = [ + { + path: ROUTES.HOME, + lazy: () => import('@/pages/landing'), + }, + { + path: ROUTES.LOGIN, + lazy: () => import('@/pages/login'), + }, + { + path: ROUTES.REGISTER, + lazy: () => import('@/pages/register'), + }, { element: , children: [ { element: , children: [ - { - path: ROUTES.HOME, - lazy: () => import('@/pages/home'), - }, { path: ROUTES.DASHBOARD, - lazy: () => import('@/pages/home'), + lazy: () => import('@/pages/dashboard'), }, { path: ROUTES.SETTINGS, - lazy: () => import('@/pages/home'), + lazy: () => import('@/pages/settings'), }, ], }, @@ -37,21 +45,22 @@ const routes: RouteObject[] = [ children: [ { element: , - children: [], + children: [ + { + path: ROUTES.ADMIN.USERS, + lazy: () => import('@/pages/admin'), + }, + ], }, ], }, { - path: ROUTES.LOGIN, - lazy: () => import('@/pages/home'), - }, - { - path: ROUTES.REGISTER, - lazy: () => import('@/pages/home'), + path: ROUTES.UNAUTHORIZED, + lazy: () => import('@/pages/landing'), }, { - path: ROUTES.UNAUTHORIZED, - lazy: () => import('@/pages/home'), + path: '*', + lazy: () => import('@/pages/landing'), }, ] diff --git a/frontend/src/core/app/shell.module.scss b/frontend/src/core/app/shell.module.scss index 8b0d30e..0776d7d 100644 --- a/frontend/src/core/app/shell.module.scss +++ b/frontend/src/core/app/shell.module.scss @@ -5,6 +5,10 @@ @use '@/styles' as *; +$sidebar-width: 240px; +$sidebar-collapsed-width: 64px; +$header-height: 56px; + .shell { display: flex; min-height: 100vh; @@ -16,19 +20,184 @@ top: 0; left: 0; bottom: 0; - width: 260px; - background: var(--color-surface); - border-right: 1px solid var(--color-border); - overflow-y: auto; + width: $sidebar-width; + background: $bg-surface-100; + border-right: 1px solid $border-default; + display: flex; + flex-direction: column; z-index: $z-fixed; + @include transition-fast; + + &.collapsed { + width: $sidebar-collapsed-width; + } - @include breakpoint-down('md') { + @include breakpoint-down('sm') { transform: translateX(-100%); - transition: transform $duration-normal $ease-in-out; &.open { transform: translateX(0); } + + &.collapsed { + width: $sidebar-width; + } + } +} + +.sidebarHeader { + height: $header-height; + padding: 0 $space-3; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid $border-default; + + .sidebar.collapsed & { + justify-content: center; + padding: 0; + } +} + +.logo { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-default; + @include transition-fast; + + .sidebar.collapsed & { + display: none; + } +} + +.nav { + flex: 1; + padding: $space-3; + display: flex; + flex-direction: column; + gap: $space-1; +} + +.navItem { + display: flex; + align-items: center; + gap: $space-3; + padding: $space-2 $space-3; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-light; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &.active { + background: $bg-selection; + color: $text-default; + } + + .sidebar.collapsed & { + justify-content: center; + } +} + +.navIcon { + width: 17px; + height: 17px; + flex-shrink: 0; +} + +.navLabel { + @include transition-fast; + + .sidebar.collapsed & { + display: none; + } +} + +.adminItem { + margin-top: auto; + border-top: 1px solid $border-default; + padding-top: $space-3; +} + +.collapseBtn { + width: 45px; + height: 45px; + border-radius: $radius-md; + color: $text-light; + @include flex-center; + @include transition-fast; + + svg { + width: 23.5px; + height: 23.5px; + } + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + @include breakpoint-down('sm') { + display: none; + } +} + +.sidebarFooter { + padding: $space-3; + border-top: 1px solid $border-default; +} + +.logoutBtn { + width: 100%; + display: flex; + align-items: center; + gap: $space-3; + padding: $space-3; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + } + + .sidebar.collapsed & { + justify-content: center; + + .logoutText { + display: none; + } + } +} + +.logoutIcon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.logoutText { + font-weight: $font-weight-medium; + @include transition-fast; +} + +.overlay { + position: fixed; + inset: 0; + background: rgb(0, 0, 0, 50%); + z-index: calc($z-fixed - 1); + display: none; + border: none; + padding: 0; + cursor: pointer; + + @include breakpoint-down('sm') { + display: block; } } @@ -36,35 +205,100 @@ flex: 1; display: flex; flex-direction: column; - margin-left: 260px; + margin-left: $sidebar-width; min-width: 0; + @include transition-fast; + + &.collapsed { + margin-left: $sidebar-collapsed-width; + } - @include breakpoint-down('md') { + @include breakpoint-down('sm') { margin-left: 0; + + &.collapsed { + margin-left: 0; + } } } .header { position: sticky; top: 0; - height: 64px; - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); + height: $header-height; + background: $bg-surface-100; + border-bottom: 1px solid $border-default; z-index: $z-sticky; - @include flex-between; - padding: 0 $space-6; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 $space-4; +} + +.headerLeft { + display: flex; + align-items: center; + gap: $space-3; +} + +.menuBtn { + display: none; + width: 36px; + height: 36px; + border-radius: $radius-md; + color: $text-light; + align-items: center; + justify-content: center; + @include transition-fast; + + svg { + width: 20px; + height: 20px; + } + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + @media (width <= 479px) { + display: flex; + } +} + +.pageTitle { + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-default; + margin-left: 7px; +} + +.headerRight { + display: flex; + align-items: center; + gap: $space-3; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: $radius-full; + background: $bg-surface-300; + color: $text-light; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + @include flex-center; } .content { flex: 1; - padding: $space-6; overflow-y: auto; } .loading { @include flex-center; height: 100%; - color: var(--color-text-muted); + color: $text-muted; } .error { @@ -72,7 +306,7 @@ height: 100%; gap: $space-4; padding: $space-6; - color: var(--color-error); + color: $error-default; h2 { font-size: $font-size-xl; @@ -80,10 +314,10 @@ } pre { - font-family: $font-code; + font-family: $font-mono; font-size: $font-size-sm; padding: $space-4; - background: var(--color-bg-subtle); + background: $bg-surface-200; border-radius: $radius-lg; overflow-x: auto; max-width: 100%; diff --git a/frontend/src/core/app/shell.tsx b/frontend/src/core/app/shell.tsx index 16ffc60..7384217 100644 --- a/frontend/src/core/app/shell.tsx +++ b/frontend/src/core/app/shell.tsx @@ -5,9 +5,25 @@ import { Suspense } from 'react' import { ErrorBoundary } from 'react-error-boundary' -import { Outlet } from 'react-router-dom' +import { GiCardAceClubs, GiCardJoker, GiExitDoor } from 'react-icons/gi' +import { LuChevronLeft, LuChevronRight, LuMenu, LuShield } from 'react-icons/lu' +import { NavLink, Outlet, useLocation } from 'react-router-dom' +import { useLogout } from '@/api/hooks' +import { ROUTES } from '@/config' +import { useIsAdmin, useUIStore, useUser } from '@/core/lib' import styles from './shell.module.scss' +const NAV_ITEMS = [ + { path: ROUTES.DASHBOARD, label: 'Dashboard', icon: GiCardJoker }, + { path: ROUTES.SETTINGS, label: 'Settings', icon: GiCardAceClubs }, +] + +const ADMIN_NAV_ITEM = { + path: ROUTES.ADMIN.USERS, + label: 'Admin', + icon: LuShield, +} + function ShellErrorFallback({ error }: { error: Error }): React.ReactElement { return (
@@ -21,13 +37,113 @@ function ShellLoading(): React.ReactElement { return
Loading...
} +function getPageTitle(pathname: string, isAdmin: boolean): string { + if (isAdmin && pathname === ADMIN_NAV_ITEM.path) { + return ADMIN_NAV_ITEM.label + } + const item = NAV_ITEMS.find((i) => i.path === pathname) + return item?.label ?? 'Dashboard' +} + export function Shell(): React.ReactElement { + const location = useLocation() + const { sidebarOpen, sidebarCollapsed, toggleSidebar, toggleSidebarCollapsed } = + useUIStore() + const { mutate: logout } = useLogout() + const isAdmin = useIsAdmin() + const user = useUser() + + const pageTitle = getPageTitle(location.pathname, isAdmin) + const avatarLetter = + user?.full_name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? 'U' + return (
- + + + {sidebarOpen && ( + +

{pageTitle}

+
-
-
{/* Header content */}
+
+
{avatarLetter}
+
+
diff --git a/frontend/src/core/app/toast.module.scss b/frontend/src/core/app/toast.module.scss index fa05002..d50ab7f 100644 --- a/frontend/src/core/app/toast.module.scss +++ b/frontend/src/core/app/toast.module.scss @@ -7,52 +7,61 @@ :global { [data-sonner-toaster] { - --normal-bg: var(--color-surface); - --normal-border: var(--color-border); - --normal-text: var(--color-text); + --normal-bg: #{$bg-surface-100}; + --normal-border: #{$border-default}; + --normal-text: #{$text-default}; - --success-bg: #{$success-900}; - --success-border: #{$success-700}; - --success-text: #{$success-100}; + --success-bg: #{$bg-surface-100}; + --success-border: #{$border-default}; + --success-text: #{$text-default}; - --error-bg: #{$error-900}; - --error-border: #{$error-700}; - --error-text: #{$error-100}; + --error-bg: #{$bg-surface-100}; + --error-border: #{$error-default}; + --error-text: #{$text-default}; - --warning-bg: #{$warning-900}; - --warning-border: #{$warning-700}; - --warning-text: #{$warning-100}; + --warning-bg: #{$bg-surface-100}; + --warning-border: #{$border-default}; + --warning-text: #{$text-default}; - --info-bg: #{$info-900}; - --info-border: #{$info-700}; - --info-text: #{$info-100}; + --info-bg: #{$bg-surface-100}; + --info-border: #{$border-default}; + --info-text: #{$text-default}; - font-family: var(--font-sans); + font-family: $font-sans; } [data-sonner-toast] { - border-radius: $radius-lg; - padding: $space-4; + border-radius: $radius-md; + padding: $space-3 $space-4; font-size: $font-size-sm; - box-shadow: $shadow-lg; + border: 1px solid $border-default; + background: $bg-surface-100; + color: $text-default; [data-title] { font-weight: $font-weight-medium; } [data-description] { - color: inherit; - opacity: 0.85; + color: $text-light; + font-size: $font-size-xs; } [data-close-button] { - @include reset-button; - @include transition-colors; - border-radius: $radius-md; + background: none; + border: none; + padding: 0; + cursor: pointer; + color: $text-muted; + @include transition-fast; @include hover { - background: rgb(255 255 255 / 10%); + color: $text-default; } } } + + [data-sonner-toast][data-type='error'] { + border-color: $error-default; + } } diff --git a/frontend/src/core/lib/auth.form.store.ts b/frontend/src/core/lib/auth.form.store.ts new file mode 100644 index 0000000..22c422c --- /dev/null +++ b/frontend/src/core/lib/auth.form.store.ts @@ -0,0 +1,43 @@ +/** + * ©AngelaMos | 2025 + * auth.form.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +interface AuthFormState { + loginEmail: string + registerEmail: string + setLoginEmail: (email: string) => void + setRegisterEmail: (email: string) => void + clearLoginForm: () => void + clearRegisterForm: () => void +} + +export const useAuthFormStore = create()( + devtools( + persist( + (set) => ({ + loginEmail: '', + registerEmail: '', + + setLoginEmail: (email) => + set({ loginEmail: email }, false, 'authForm/setLoginEmail'), + + setRegisterEmail: (email) => + set({ registerEmail: email }, false, 'authForm/setRegisterEmail'), + + clearLoginForm: () => + set({ loginEmail: '' }, false, 'authForm/clearLoginForm'), + + clearRegisterForm: () => + set({ registerEmail: '' }, false, 'authForm/clearRegisterForm'), + }), + { + name: 'auth-form-storage', + } + ), + { name: 'AuthFormStore' } + ) +) diff --git a/frontend/src/core/lib/index.ts b/frontend/src/core/lib/index.ts index 7fcffd5..191757f 100644 --- a/frontend/src/core/lib/index.ts +++ b/frontend/src/core/lib/index.ts @@ -3,5 +3,6 @@ // index.ts // =================== +export * from './auth.form.store' export * from './auth.store' -export * from './ui.store' +export * from './shell.ui.store' diff --git a/frontend/src/core/lib/ui.store.ts b/frontend/src/core/lib/shell.ui.store.ts similarity index 100% rename from frontend/src/core/lib/ui.store.ts rename to frontend/src/core/lib/shell.ui.store.ts diff --git a/frontend/src/pages/admin/admin.module.scss b/frontend/src/pages/admin/admin.module.scss new file mode 100644 index 0000000..ef51500 --- /dev/null +++ b/frontend/src/pages/admin/admin.module.scss @@ -0,0 +1,468 @@ +// =================== +// © AngelaMos | 2025 +// admin.module.scss +// =================== + +@use '@/styles' as *; + +.page { + padding: $space-6; + min-height: calc(100vh - 56px); + background-color: $bg-default; +} + +.header { + @include flex-between; + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.createBtn { + display: flex; + align-items: center; + gap: $space-2; + padding: $space-2 $space-4; + background-color: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + svg { + width: 16px; + height: 16px; + } +} + +.table { + width: 100%; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + overflow: hidden; +} + +.tableHeader { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + background: $bg-surface-200; + border-bottom: 1px solid $border-default; + + @include breakpoint-down('md') { + display: none; + } +} + +.tableHeaderCell { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: $tracking-wide; +} + +.tableBody { + @include flex-column; +} + +.tableRow { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + border-bottom: 1px solid $border-default; + @include transition-fast; + + &:last-child { + border-bottom: none; + } + + @include hover { + background: $bg-surface-75; + } + + @include breakpoint-down('md') { + grid-template-columns: 1fr; + gap: $space-2; + } +} + +.tableCell { + display: flex; + align-items: center; + font-size: $font-size-sm; + color: $text-default; + min-width: 0; + + @include breakpoint-down('md') { + &::before { + content: attr(data-label); + font-size: $font-size-xs; + color: $text-lighter; + margin-right: $space-2; + min-width: 80px; + } + } +} + +.email { + @include truncate; +} + +.badge { + display: inline-flex; + align-items: center; + padding: $space-1 $space-2; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &.admin { + background: $bg-selection; + color: $text-default; + } + + &.user { + background: $bg-surface-200; + color: $text-light; + } + + &.active { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &.inactive { + background: hsl(0 72% 51% / 20%); + color: $error-light; + } +} + +.actions { + display: flex; + gap: $space-2; + justify-content: flex-end; + + @include breakpoint-down('md') { + justify-content: flex-start; + } +} + +.actionBtn { + width: 32px; + height: 32px; + @include flex-center; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &.delete { + @include hover { + border-color: $error-default; + color: $error-default; + } + } + + svg { + width: 16px; + height: 16px; + } +} + +.pagination { + @include flex-between; + padding: $space-4; + border-top: 1px solid $border-default; +} + +.paginationInfo { + font-size: $font-size-sm; + color: $text-lighter; +} + +.paginationBtns { + display: flex; + gap: $space-2; +} + +.paginationBtn { + padding: $space-2 $space-3; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + font-size: $font-size-sm; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.empty { + @include flex-column-center; + padding: $space-12; + color: $text-muted; + font-size: $font-size-sm; +} + +.loading { + @include flex-center; + padding: $space-12; + color: $text-muted; +} + +.modal { + position: fixed; + inset: 0; + z-index: $z-modal; + @include flex-center; +} + +.modalOverlay { + @include absolute-fill; + background: rgb(0, 0, 0, 70%); +} + +.modalContent { + position: relative; + width: 100%; + max-width: 400px; + margin: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-6; +} + +.modalHeader { + @include flex-between; + margin-bottom: $space-5; +} + +.modalTitle { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.modalClose { + width: 32px; + height: 32px; + @include flex-center; + border: none; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + svg { + width: 20px; + height: 20px; + } +} + +.form { + @include flex-column; + gap: $space-4; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; +} + +.input { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } +} + +.select { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + cursor: pointer; + @include transition-fast; + + &:focus { + outline: none; + border-color: $border-strong; + } + + option { + background: $bg-surface-100; + color: $text-default; + } +} + +.checkbox { + display: flex; + align-items: center; + gap: $space-2; + cursor: pointer; + + input { + width: 18px; + height: 18px; + accent-color: $white; + } + + span { + font-size: $font-size-sm; + color: $text-light; + } +} + +.formActions { + display: flex; + gap: $space-3; + margin-top: $space-2; +} + +.submitBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.cancelBtn { + flex: 1; + height: 44px; + @include flex-center; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } +} + +.deleteConfirm { + @include flex-column; + gap: $space-4; +} + +.deleteText { + font-size: $font-size-sm; + color: $text-light; + line-height: $line-height-relaxed; +} + +.deleteEmail { + font-weight: $font-weight-medium; + color: $text-default; +} + +.deleteBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $error-default; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} diff --git a/frontend/src/pages/admin/index.tsx b/frontend/src/pages/admin/index.tsx new file mode 100644 index 0000000..53fa22a --- /dev/null +++ b/frontend/src/pages/admin/index.tsx @@ -0,0 +1,415 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { LuPencil, LuPlus, LuTrash2, LuX } from 'react-icons/lu' +import { + useAdminCreateUser, + useAdminDeleteUser, + useAdminUpdateUser, + useAdminUsers, +} from '@/api/hooks' +import type { UserResponse } from '@/api/types' +import { UserRole } from '@/api/types' +import { PAGINATION } from '@/config' +import styles from './admin.module.scss' + +type ModalState = + | { type: 'closed' } + | { type: 'create' } + | { type: 'edit'; user: UserResponse } + | { type: 'delete'; user: UserResponse } + +export function Component(): React.ReactElement { + const [page, setPage] = useState(PAGINATION.DEFAULT_PAGE) + const [modal, setModal] = useState({ type: 'closed' }) + + const { data, isLoading } = useAdminUsers({ + page, + size: PAGINATION.DEFAULT_SIZE, + }) + const createUser = useAdminCreateUser() + const updateUser = useAdminUpdateUser() + const deleteUser = useAdminDeleteUser() + + const handleCreate = (formData: FormData): void => { + const email = formData.get('email') as string + const password = formData.get('password') as string + const fullName = (formData.get('fullName') as string) || undefined + const role = formData.get('role') as UserRole + const isActive = formData.get('isActive') === 'on' + + createUser.mutate( + { email, password, full_name: fullName, role, is_active: isActive }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const handleUpdate = (userId: string, formData: FormData): void => { + const email = formData.get('email') as string + const fullName = (formData.get('fullName') as string) || undefined + const role = formData.get('role') as UserRole + const isActive = formData.get('isActive') === 'on' + + updateUser.mutate( + { + id: userId, + data: { email, full_name: fullName, role, is_active: isActive }, + }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const handleDelete = (userId: string): void => { + deleteUser.mutate(userId, { onSuccess: () => setModal({ type: 'closed' }) }) + } + + const totalPages = data ? Math.ceil(data.total / PAGINATION.DEFAULT_SIZE) : 0 + + return ( +
+
+

Users

+ +
+ +
+
+
Email
+
Name
+
Role
+
Status
+
Actions
+
+ +
+ {isLoading &&
Loading...
} + + {!isLoading && data?.items.length === 0 && ( +
No users found
+ )} + + {data?.items.map((user) => ( +
+
+ {user.email} +
+
+ {user.full_name ?? '—'} +
+
+ + {user.role} + +
+
+ + {user.is_active ? 'Active' : 'Inactive'} + +
+
+ + +
+
+ ))} +
+ + {data && data.total > PAGINATION.DEFAULT_SIZE && ( +
+ + Page {page} of {totalPages} ({data.total} users) + +
+ + +
+
+ )} +
+ + {modal.type === 'create' && ( +
+ +
+
{ + e.preventDefault() + handleCreate(new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+ )} + + {modal.type === 'edit' && ( +
+ +
+
{ + e.preventDefault() + handleUpdate(modal.user.id, new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+ + )} + + {modal.type === 'delete' && ( +
+ +
+
+

+ Are you sure you want to delete{' '} + {modal.user.email}? + This action cannot be undone. +

+
+ + +
+
+ + + )} + + ) +} + +Component.displayName = 'AdminUsers' diff --git a/frontend/src/pages/dashboard/dashboard.module.scss b/frontend/src/pages/dashboard/dashboard.module.scss new file mode 100644 index 0000000..ecac295 --- /dev/null +++ b/frontend/src/pages/dashboard/dashboard.module.scss @@ -0,0 +1,152 @@ +// =================== +// © AngelaMos | 2025 +// dashboard.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: calc(100vh - 56px); + padding: $space-6; + background-color: $bg-default; +} + +.container { + max-width: 800px; + margin: 0 auto; +} + +.header { + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-sm; + color: $text-lighter; +} + +.userCard { + display: flex; + align-items: center; + gap: $space-4; + padding: $space-5; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + margin-bottom: $space-8; +} + +.avatar { + width: 56px; + height: 56px; + border-radius: $radius-full; + background: $bg-surface-300; + color: $text-default; + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + @include flex-center; + flex-shrink: 0; +} + +.userInfo { + @include flex-column; + gap: $space-1; + min-width: 0; +} + +.userName { + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-default; + @include truncate; +} + +.userEmail { + font-size: $font-size-sm; + color: $text-light; + @include truncate; +} + +.userRole { + display: inline-flex; + align-self: flex-start; + padding: $space-0-5 $space-2; + background: $bg-surface-200; + border-radius: $radius-full; + font-size: $font-size-xs; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: $tracking-wide; +} + +.section { + margin-bottom: $space-8; +} + +.sectionTitle { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + text-transform: uppercase; + letter-spacing: $tracking-wide; + margin-bottom: $space-4; +} + +.grid { + display: grid; + gap: $space-4; + + @include breakpoint-up('md') { + grid-template-columns: repeat(3, 1fr); + } +} + +.card { + padding: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; +} + +.hookName { + display: inline-block; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; + background: $bg-surface-200; + padding: $space-1 $space-2; + border-radius: $radius-sm; + margin-bottom: $space-2; +} + +.description { + font-size: $font-size-sm; + color: $text-light; + margin-bottom: $space-2; + line-height: $line-height-relaxed; +} + +.file { + font-size: $font-size-xs; + color: $text-muted; + font-family: $font-mono; +} + +.list { + @include flex-column; + gap: $space-2; + padding-left: $space-5; + list-style: disc; + + li { + font-size: $font-size-sm; + color: $text-light; + } +} diff --git a/frontend/src/pages/dashboard/index.tsx b/frontend/src/pages/dashboard/index.tsx new file mode 100644 index 0000000..5d3f97e --- /dev/null +++ b/frontend/src/pages/dashboard/index.tsx @@ -0,0 +1,90 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useUser } from '@/core/lib' +import styles from './dashboard.module.scss' + +const AVAILABLE_STORES = [ + { + name: 'useUser()', + file: 'core/lib/auth.store.ts', + description: 'Get current authenticated user', + }, + { + name: 'useIsAuthenticated()', + file: 'core/lib/auth.store.ts', + description: 'Check if user is logged in', + }, + { + name: 'useIsAdmin()', + file: 'core/lib/auth.store.ts', + description: 'Check if user has admin role', + }, +] + +const SUGGESTED_FEATURES = [ + 'User stats and metrics', + 'Recent activity feed', + 'Quick actions', + 'Charts and analytics', + 'Notifications overview', + 'Task/project summary', +] + +export function Component(): React.ReactElement { + const user = useUser() + + return ( +
+
+
+

+ Welcome{user?.full_name ? `, ${user.full_name}` : ''} +

+

+ Template page — build your dashboard here +

+
+ +
+
+ {user?.full_name?.[0]?.toUpperCase() ?? + user?.email?.[0]?.toUpperCase() ?? + 'U'} +
+
+ {user?.full_name ?? 'User'} + {user?.email} + {user?.role} +
+
+ +
+

Available Stores

+
+ {AVAILABLE_STORES.map((store) => ( +
+ {store.name} +

{store.description}

+ {store.file} +
+ ))} +
+
+ +
+

Suggested Features

+
    + {SUGGESTED_FEATURES.map((feature) => ( +
  • {feature}
  • + ))} +
+
+
+
+ ) +} + +Component.displayName = 'Dashboard' diff --git a/frontend/src/pages/home/home.module.scss b/frontend/src/pages/home/home.module.scss deleted file mode 100644 index 5aff896..0000000 --- a/frontend/src/pages/home/home.module.scss +++ /dev/null @@ -1,18 +0,0 @@ -// =================== -// © AngelaMos | 2025 -// home.module.scss -// =================== - -@use '@/styles' as *; - -.home { - h1 { - font-size: $font-size-3xl; - font-weight: $font-weight-bold; - margin-bottom: $space-4; - } - - p { - color: var(--color-text-muted); - } -} diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx deleted file mode 100644 index 88ce814..0000000 --- a/frontend/src/pages/home/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** - * ©AngelaMos | 2025 - * index.tsx - */ - -import styles from './home.module.scss' - -export function Component(): React.ReactElement { - return ( -
-

Home

-

Welcome to the template.

-
- ) -} - -Component.displayName = 'Home' diff --git a/frontend/src/pages/landing/index.tsx b/frontend/src/pages/landing/index.tsx new file mode 100644 index 0000000..17d697a --- /dev/null +++ b/frontend/src/pages/landing/index.tsx @@ -0,0 +1,97 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { FiGithub } from 'react-icons/fi' +import { Link } from 'react-router-dom' +import { ROUTES } from '@/config' +import styles from './landing.module.scss' + +export function Component(): React.ReactElement { + return ( +
+
+

Full Stack Template

+

by Carter Perez

+ + + +
+ +
+

+ Boilerplate for medium-large scale applications. Built with modern + patterns, strict typing, and security best practices. +

+ +
+
+

Frontend

+
    +
  • React 19 + TypeScript with strict mode
  • +
  • TanStack Query for server state caching
  • +
  • Zustand stores with persistence
  • +
  • Axios interceptors with auto token refresh
  • +
  • Zod runtime validation on API responses
  • +
  • SCSS modules with design tokens
  • +
+
+ +
+

Backend

+
    +
  • DDD + DI Architecture
  • +
  • FastAPI with async/await throughout
  • +
  • SQLAlchemy 2.0+ async with connection pooling
  • +
  • JWT auth with token rotation and replay detection
  • +
  • Argon2id hashing with timing safe verification
  • +
  • Pydantic v2 strict validation
  • +
+
+ +
+

Infrastructure

+
    +
  • Docker Compose with dev/prod configs
  • +
  • Nginx reverse proxy with rate limiting
  • +
  • PostgreSQL 18 + Redis 7
  • +
  • Health checks and graceful shutdown
  • +
+
+ +
+

DevOps

+
    +
  • GitHub Actions CI (Ruff, Pylint, Mypy, Biome)
  • +
  • Strict type checking on both ends
  • +
  • Alembic async migrations
  • +
+
+
+ +
+ + Try Auth Flow + + + API Docs + +
+
+
+ ) +} + +Component.displayName = 'Landing' diff --git a/frontend/src/pages/landing/landing.module.scss b/frontend/src/pages/landing/landing.module.scss new file mode 100644 index 0000000..3013e87 --- /dev/null +++ b/frontend/src/pages/landing/landing.module.scss @@ -0,0 +1,161 @@ +// =================== +// © AngelaMos | 2025 +// landing.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: 100vh; + min-height: 100dvh; + @include flex-column-center; + background-color: $bg-default; + background-image: radial-gradient( + circle, + $bg-landing 1px, + transparent 1px + ); + background-size: 20px 20px; + padding: $space-8; +} + +.header { + text-align: center; + margin-bottom: $space-5; +} + +.title { + font-size: $font-size-4xl; + font-weight: $font-weight-semibold; + color: $text-default; + letter-spacing: $tracking-tight; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-lg; + color: $text-light; + margin-bottom: $space-3; +} + +.github { + display: inline-flex; + color: $text-muted; + font-size: $font-size-2xl; + @include transition-fast; + + @include hover { + &:hover { + color: $text-default; + } + } +} + +.content { + max-width: 800px; + text-align: center; +} + +.description { + font-size: $font-size-base; + color: $text-light; + line-height: $line-height-relaxed; + margin-bottom: $space-8; +} + +.sections { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: $space-6; + margin-bottom: $space-10; + text-align: left; + + @include breakpoint-down(md) { + grid-template-columns: 1fr; + } +} + +.section { + padding: $space-5; + background-color: $bg-surface-75; + border: 1px solid $border-muted; + border-radius: $radius-lg; +} + +.sectionTitle { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-3; + letter-spacing: $tracking-wide; + text-transform: uppercase; +} + +.features { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: $space-2; + + li { + font-size: $font-size-sm; + color: $text-muted; + + &::before { + content: '→'; + margin-right: $space-2; + color: $text-lighter; + } + } +} + +.actions { + display: flex; + gap: $space-3; + justify-content: center; + flex-wrap: wrap; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: $space-3 $space-5; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $bg-default; + background-color: $text-default; + border-radius: $radius-md; + text-decoration: none; + @include transition-fast; + + @include hover { + &:hover { + filter: brightness(0.9); + } + } +} + +.buttonOutline { + display: inline-flex; + align-items: center; + justify-content: center; + padding: $space-3 $space-5; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; + background-color: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + text-decoration: none; + @include transition-fast; + + @include hover { + &:hover { + border-color: $border-strong; + background-color: $bg-surface-75; + } + } +} diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx new file mode 100644 index 0000000..13caa91 --- /dev/null +++ b/frontend/src/pages/login/index.tsx @@ -0,0 +1,114 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { LuEye, LuEyeOff } from 'react-icons/lu' +import { Link, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { useLogin } from '@/api/hooks' +import { loginRequestSchema } from '@/api/types' +import { ROUTES } from '@/config' +import { useAuthFormStore } from '@/core/lib' +import styles from './login.module.scss' + +export function Component(): React.ReactElement { + const navigate = useNavigate() + const login = useLogin() + + const { loginEmail, setLoginEmail, clearLoginForm } = useAuthFormStore() + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault() + + const result = loginRequestSchema.safeParse({ + username: loginEmail, + password, + }) + + if (!result.success) { + const firstError = result.error.issues[0] + toast.error(firstError.message) + return + } + + login.mutate(result.data, { + onSuccess: () => { + clearLoginForm() + navigate(ROUTES.DASHBOARD) + }, + }) + } + + return ( +
+
+
+

Login

+

Welcome back

+
+ +
+
+ + setLoginEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + autoComplete="current-password" + /> + +
+
+ + +
+ +

+ Don't have an account?{' '} + + Sign up + +

+
+
+ ) +} + +Component.displayName = 'Login' diff --git a/frontend/src/pages/login/login.module.scss b/frontend/src/pages/login/login.module.scss new file mode 100644 index 0000000..6d01ed2 --- /dev/null +++ b/frontend/src/pages/login/login.module.scss @@ -0,0 +1,171 @@ +// =================== +// © AngelaMos | 2025 +// login.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: 100vh; + min-height: 100dvh; + @include flex-center; + background-color: $bg-default; + background-image: radial-gradient( + circle, + $bg-page 1px, + transparent 1px + ); + background-size: 22px 22px; + padding: $space-4; +} + +.card { + width: 100%; + max-width: 400px; + background-color: $black; + background-image: radial-gradient( + circle, + $bg-card 1px, + transparent 1px + ); + background-size: 20px 20px; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-8; +} + +.header { + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $white; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-sm; + color: $text-light; +} + +.form { + @include flex-column; + gap: $space-5; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; +} + +.input { + width: 100%; + height: 48px; + padding: 0 $space-4; + background-color: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-base; + color: $white; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } + + &[aria-invalid='true'] { + border-color: $error-default; + } +} + +.inputWrapper { + position: relative; + width: 100%; +} + +.eyeButton { + position: absolute; + right: $space-3; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: $text-muted; + cursor: pointer; + padding: $space-1; + @include transition-fast; + + &:hover { + color: $white; + } + + svg { + width: 20px; + height: 20px; + } +} + +.error { + font-size: $font-size-xs; + color: $error-default; +} + +.submit { + width: 100%; + height: 48px; + margin-top: $space-2; + display: flex; + align-items: center; + justify-content: center; + background-color: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.footer { + margin-top: $space-6; + text-align: center; + font-size: $font-size-sm; + color: $text-light; +} + +.link { + color: $text-default; + text-decoration: underline; + text-underline-offset: 4px; + @include transition-fast; + + @include hover { + color: $text-light; + } +} diff --git a/frontend/src/pages/register/index.tsx b/frontend/src/pages/register/index.tsx new file mode 100644 index 0000000..f0f003f --- /dev/null +++ b/frontend/src/pages/register/index.tsx @@ -0,0 +1,164 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { LuEye, LuEyeOff } from 'react-icons/lu' +import { Link, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { z } from 'zod' +import { useRegister } from '@/api/hooks' +import { userCreateRequestSchema } from '@/api/types' +import { PASSWORD_CONSTRAINTS, ROUTES } from '@/config' +import { useAuthFormStore } from '@/core/lib' +import styles from './register.module.scss' + +const registerFormSchema = userCreateRequestSchema + .extend({ + confirmPassword: z + .string() + .min( + PASSWORD_CONSTRAINTS.MIN_LENGTH, + `Password must be at least ${PASSWORD_CONSTRAINTS.MIN_LENGTH} characters` + ) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }) + +export function Component(): React.ReactElement { + const navigate = useNavigate() + const register = useRegister() + + const { registerEmail, setRegisterEmail, clearRegisterForm } = + useAuthFormStore() + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault() + + const result = registerFormSchema.safeParse({ + email: registerEmail, + password, + confirmPassword, + }) + + if (!result.success) { + const firstError = result.error.issues[0] + toast.error(firstError.message) + return + } + + register.mutate( + { email: result.data.email, password: result.data.password }, + { + onSuccess: () => { + clearRegisterForm() + toast.success('Account created successfully') + navigate(ROUTES.LOGIN) + }, + } + ) + } + + return ( +
+
+
+

Sign up

+

Create a new account

+
+ +
+
+ + setRegisterEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + autoComplete="new-password" + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> + +
+
+ + +
+ +

+ Already have an account?{' '} + + Login + +

+
+
+ ) +} + +Component.displayName = 'Register' diff --git a/frontend/src/pages/register/register.module.scss b/frontend/src/pages/register/register.module.scss new file mode 100644 index 0000000..55ad571 --- /dev/null +++ b/frontend/src/pages/register/register.module.scss @@ -0,0 +1,171 @@ +// =================== +// © AngelaMos | 2025 +// register.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: 100vh; + min-height: 100dvh; + @include flex-center; + background-color: $bg-default; + background-image: radial-gradient( + circle, + $bg-page 1px, + transparent 1px + ); + background-size: 22px 22px; + padding: $space-4; +} + +.card { + width: 100%; + max-width: 400px; + background-color: $black; + background-image: radial-gradient( + circle, + $bg-card 1px, + transparent 1px + ); + background-size: 20px 20px; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-8; +} + +.header { + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $white; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-sm; + color: $text-light; +} + +.form { + @include flex-column; + gap: $space-5; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; +} + +.input { + width: 100%; + height: 48px; + padding: 0 $space-4; + background-color: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-base; + color: $white; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } + + &[aria-invalid='true'] { + border-color: $error-default; + } +} + +.inputWrapper { + position: relative; + width: 100%; +} + +.eyeButton { + position: absolute; + right: $space-3; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: $text-muted; + cursor: pointer; + padding: $space-1; + @include transition-fast; + + &:hover { + color: $white; + } + + svg { + width: 20px; + height: 20px; + } +} + +.error { + font-size: $font-size-xs; + color: $error-default; +} + +.submit { + width: 100%; + height: 48px; + margin-top: $space-2; + display: flex; + align-items: center; + justify-content: center; + background-color: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.footer { + margin-top: $space-6; + text-align: center; + font-size: $font-size-sm; + color: $text-light; +} + +.link { + color: $text-default; + text-decoration: underline; + text-underline-offset: 4px; + @include transition-fast; + + @include hover { + color: $text-light; + } +} diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx new file mode 100644 index 0000000..8a22eb6 --- /dev/null +++ b/frontend/src/pages/settings/index.tsx @@ -0,0 +1,95 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import styles from './settings.module.scss' + +const AVAILABLE_HOOKS = [ + { + name: 'useUpdateProfile()', + file: 'api/hooks/useUsers.ts', + description: 'Update user profile (full_name)', + endpoint: 'PATCH /api/v1/users/me', + }, + { + name: 'useChangePassword()', + file: 'api/hooks/useAuth.ts', + description: 'Change password (current + new)', + endpoint: 'POST /api/v1/auth/change-password', + }, +] + +const AVAILABLE_STORES = [ + { + name: 'useAuthStore()', + file: 'core/lib/auth.store.ts', + description: 'Access user state, logout, updateUser', + }, + { + name: 'useUser()', + file: 'core/lib/auth.store.ts', + description: 'Get current user from store', + }, +] + +export function Component(): React.ReactElement { + return ( +
+
+
+

Settings

+

+ Template page — available hooks and stores for building your settings + UI +

+
+ +
+

Available Hooks

+
+ {AVAILABLE_HOOKS.map((hook) => ( +
+ {hook.name} +

{hook.description}

+
+ {hook.file} + {hook.endpoint} +
+
+ ))} +
+
+ +
+

Available Stores

+
+ {AVAILABLE_STORES.map((store) => ( +
+ {store.name} +

{store.description}

+
+ {store.file} +
+
+ ))} +
+
+ +
+

Suggested Features

+
    +
  • Profile form (full name, avatar)
  • +
  • Change password form
  • +
  • Email preferences
  • +
  • Theme toggle (dark/light)
  • +
  • Notification settings
  • +
  • Delete account
  • +
+
+
+
+ ) +} + +Component.displayName = 'Settings' diff --git a/frontend/src/pages/settings/settings.module.scss b/frontend/src/pages/settings/settings.module.scss new file mode 100644 index 0000000..16d8585 --- /dev/null +++ b/frontend/src/pages/settings/settings.module.scss @@ -0,0 +1,109 @@ +// =================== +// © AngelaMos | 2025 +// settings.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: calc(100vh - 56px); + padding: $space-6; + background-color: $bg-default; +} + +.container { + max-width: 800px; + margin: 0 auto; +} + +.header { + margin-bottom: $space-8; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-sm; + color: $text-lighter; +} + +.section { + margin-bottom: $space-8; +} + +.sectionTitle { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + text-transform: uppercase; + letter-spacing: $tracking-wide; + margin-bottom: $space-4; +} + +.grid { + display: grid; + gap: $space-4; + + @include breakpoint-up('md') { + grid-template-columns: repeat(2, 1fr); + } +} + +.card { + padding: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; +} + +.hookName { + display: inline-block; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; + background: $bg-surface-200; + padding: $space-1 $space-2; + border-radius: $radius-sm; + margin-bottom: $space-2; +} + +.description { + font-size: $font-size-sm; + color: $text-light; + margin-bottom: $space-3; + line-height: $line-height-relaxed; +} + +.meta { + @include flex-column; + gap: $space-1; +} + +.file { + font-size: $font-size-xs; + color: $text-lighter; + font-family: $font-mono; +} + +.endpoint { + font-size: $font-size-xs; + color: $text-muted; + font-family: $font-mono; +} + +.list { + @include flex-column; + gap: $space-2; + padding-left: $space-5; + list-style: disc; + + li { + font-size: $font-size-sm; + color: $text-light; + } +} diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index e95b5f2..4ac95fe 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -16,14 +16,14 @@ min-height: 100dvh; display: flex; flex-direction: column; - background: var(--color-bg); + background: $bg-default; } .app { flex: 1; display: flex; flex-direction: column; - background: var(--color-bg); - color: var(--color-text); - font-family: var(--font-sans); + background: $bg-default; + color: $text-default; + font-family: $font-sans; } diff --git a/frontend/src/styles/_fonts.scss b/frontend/src/styles/_fonts.scss index 27a244b..4f51788 100644 --- a/frontend/src/styles/_fonts.scss +++ b/frontend/src/styles/_fonts.scss @@ -5,114 +5,8 @@ @use 'tokens' as *; -// ============================================================================ -// FONT IMPORTS -// ============================================================================ +$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, + 'Helvetica Neue', Arial, sans-serif; -// System font stack (no external imports needed) -$font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, - 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; - -$font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', - monospace; - -// ============================================================================ -// FONT FACE DECLARATIONS (uncomment and customize as needed) -// ============================================================================ - -// Example: Inter font -// @font-face { -// font-family: 'Inter'; -// font-style: normal; -// font-weight: 100 900; -// font-display: swap; -// src: url('/fonts/Inter-Variable.woff2') format('woff2'); -// } - -// Example: Custom font with multiple weights -// @font-face { -// font-family: 'CustomFont'; -// font-style: normal; -// font-weight: 400; -// font-display: swap; -// src: url('/fonts/CustomFont-Regular.woff2') format('woff2'); -// } -// -// @font-face { -// font-family: 'CustomFont'; -// font-style: normal; -// font-weight: 700; -// font-display: swap; -// src: url('/fonts/CustomFont-Bold.woff2') format('woff2'); -// } - -// FONT FAMILY VARIABLES -$font-sans: $font-system; -$font-display: $font-system; -$font-body: $font-system; -$font-code: $font-mono; - -// FONT UTILITY CLASSES -%font-display { - font-family: $font-display; - font-weight: $font-weight-bold; - line-height: $line-height-tight; - letter-spacing: $tracking-tight; -} - -%font-heading { - font-family: $font-sans; - font-weight: $font-weight-semibold; - line-height: $line-height-tight; - letter-spacing: $tracking-tight; -} - -%font-subheading { - font-family: $font-sans; - font-weight: $font-weight-medium; - line-height: $line-height-snug; - letter-spacing: $tracking-normal; -} - -%font-body { - font-family: $font-body; - font-weight: $font-weight-regular; - line-height: $line-height-normal; - letter-spacing: $tracking-normal; -} - -%font-body-medium { - font-family: $font-body; - font-weight: $font-weight-medium; - line-height: $line-height-normal; - letter-spacing: $tracking-normal; -} - -%font-caption { - font-family: $font-body; - font-weight: $font-weight-regular; - line-height: $line-height-normal; - letter-spacing: $tracking-wide; -} - -%font-label { - font-family: $font-sans; - font-weight: $font-weight-medium; - line-height: $line-height-none; - letter-spacing: $tracking-wide; - text-transform: uppercase; -} - -%font-code { - font-family: $font-code; - font-weight: $font-weight-regular; - line-height: $line-height-normal; - letter-spacing: $tracking-normal; -} - -// BASE TYPOGRAPHY SETUP -:root { - --font-sans: #{$font-sans}; - --font-mono: #{$font-mono}; -} +$font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + 'Liberation Mono', monospace; diff --git a/frontend/src/styles/_mixins.scss b/frontend/src/styles/_mixins.scss index b9d3da7..6b5b1ba 100644 --- a/frontend/src/styles/_mixins.scss +++ b/frontend/src/styles/_mixins.scss @@ -3,12 +3,10 @@ // _mixins.scss // =================== -@use 'sass:math'; @use 'sass:map'; @use 'sass:list'; @use 'tokens' as *; -// BREAKPOINT MIXINS $breakpoints: ( 'xs': $breakpoint-xs, 'sm': $breakpoint-sm, @@ -16,7 +14,6 @@ $breakpoints: ( 'lg': $breakpoint-lg, 'xl': $breakpoint-xl, '2xl': $breakpoint-2xl, - '3xl': $breakpoint-3xl, ); @mixin breakpoint-up($size) { @@ -26,66 +23,11 @@ $breakpoints: ( } @mixin breakpoint-down($size) { - @media (width <= calc(map.get($breakpoints, $size) - 1px)) { + @media (width < map.get($breakpoints, $size)) { @content; } } -@mixin breakpoint-between($min, $max) { - @media (min-width: map.get($breakpoints, $min)) and (width <= calc(map.get($breakpoints, $max) - 1px)) { - @content; - } -} - -@mixin breakpoint-only($size) { - $keys: map.keys($breakpoints); - $index: list.index($keys, $size); - $next-index: $index + 1; - - @if $next-index <= list.length($keys) { - $next-key: list.nth($keys, $next-index); - @include breakpoint-between($size, $next-key) { - @content; - } - } @else { - @include breakpoint-up($size) { - @content; - } - } -} - - -@mixin container($name: null) { - container-type: inline-size; - @if $name { - container-name: #{$name}; - } -} - -@mixin container-up($min-width, $name: null) { - @if $name { - @container #{$name} (min-width: #{$min-width}) { - @content; - } - } @else { - @container (min-width: #{$min-width}) { - @content; - } - } -} - -@function fluid-size($min-size, $max-size, $min-vw: 360px, $max-vw: 1536px) { - $slope: math.div($max-size - $min-size, $max-vw - $min-vw); - $intercept: $min-size - ($slope * $min-vw); - @return clamp(#{$min-size}, #{$intercept} + #{$slope * 100}vw, #{$max-size}); -} - -@mixin fluid-type($min-size, $max-size, $min-vw: 360px, $max-vw: 1536px) { - font-size: fluid-size($min-size, $max-size, $min-vw, $max-vw); -} - - -// FLEXBOX UTILITIES @mixin flex-center { display: flex; align-items: center; @@ -98,18 +40,6 @@ $breakpoints: ( justify-content: space-between; } -@mixin flex-start { - display: flex; - align-items: center; - justify-content: flex-start; -} - -@mixin flex-end { - display: flex; - align-items: center; - justify-content: flex-end; -} - @mixin flex-column { display: flex; flex-direction: column; @@ -122,20 +52,6 @@ $breakpoints: ( justify-content: center; } -// GRID UTILITIES -@mixin grid-auto-fit($min-width: 250px, $gap: $space-4) { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, #{$min-width}), 1fr)); - gap: $gap; -} - -@mixin grid-auto-fill($min-width: 250px, $gap: $space-4) { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, #{$min-width}), 1fr)); - gap: $gap; -} - -// ACCESSIBILITY @mixin sr-only { position: absolute; width: 1px; @@ -149,37 +65,6 @@ $breakpoints: ( border: 0; } -@mixin sr-only-focusable { - @include sr-only; - - &:focus, - &:active { - position: static; - width: auto; - height: auto; - margin: 0; - overflow: visible; - clip: auto; - clip-path: none; - white-space: normal; - } -} - -@mixin focus-ring($color: currentcolor, $offset: 2px, $width: 2px) { - &:focus-visible { - outline: #{$width} solid #{$color}; - outline-offset: #{$offset}; - } -} - -@mixin focus-ring-inset($color: currentcolor, $width: 2px) { - &:focus-visible { - outline: #{$width} solid #{$color}; - outline-offset: -#{$width}; - } -} - -// TRUNCATION @mixin truncate { overflow: hidden; text-overflow: ellipsis; @@ -193,102 +78,23 @@ $breakpoints: ( overflow: hidden; } -// BUTTON RESET -@mixin reset-button { - appearance: none; - background: none; - border: none; - padding: 0; - margin: 0; - font: inherit; - color: inherit; - cursor: pointer; - text-align: inherit; - - &:disabled { - cursor: not-allowed; - opacity: 0.6; - } -} - -// INPUT RESET -@mixin reset-input { - appearance: none; - background: transparent; - border: none; - padding: 0; - margin: 0; - font: inherit; - color: inherit; - outline: none; - - &::placeholder { - color: inherit; - opacity: 0.5; - } -} - -// SCROLLBAR -@mixin hide-scrollbar { - -ms-overflow-style: none; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } -} - -@mixin thin-scrollbar($thumb-color: rgb(255 255 255 / 20%), $track-color: transparent) { - scrollbar-width: thin; - scrollbar-color: #{$thumb-color} #{$track-color}; - - &::-webkit-scrollbar { - width: 6px; - height: 6px; - } - - &::-webkit-scrollbar-track { - background: #{$track-color}; - } - - &::-webkit-scrollbar-thumb { - background: #{$thumb-color}; - border-radius: 3px; - } -} - -// TRANSITIONS -@mixin transition($properties: all, $duration: $duration-normal, $easing: $ease-in-out) { - transition-property: #{$properties}; - transition-duration: #{$duration}; - transition-timing-function: #{$easing}; -} - -@mixin transition-transform($duration: $duration-normal, $easing: $ease-in-out) { - transition: transform #{$duration} #{$easing}; +@mixin transition-fast { + transition-property: background-color, border-color, color, opacity; + transition-duration: $duration-fast; + transition-timing-function: $ease-out; } -@mixin transition-opacity($duration: $duration-normal, $easing: $ease-in-out) { - transition: opacity #{$duration} #{$easing}; +@mixin transition-normal { + transition-property: background-color, border-color, color, opacity; + transition-duration: $duration-normal; + transition-timing-function: $ease-out; } -@mixin transition-colors($duration: $duration-normal, $easing: $ease-in-out) { - transition: color #{$duration} #{$easing}, - background-color #{$duration} #{$easing}, - border-color #{$duration} #{$easing}; -} - -// POSITION UTILITIES @mixin absolute-fill { position: absolute; inset: 0; } -@mixin fixed-fill { - position: fixed; - inset: 0; -} - @mixin absolute-center { position: absolute; top: 50%; @@ -296,20 +102,6 @@ $breakpoints: ( transform: translate(-50%, -50%); } -// ASPECT RATIO (for older browser support alongside native) -@mixin aspect-ratio($width, $height) { - aspect-ratio: #{$width} / #{$height}; - - @supports not (aspect-ratio: 1 / 1) { - &::before { - content: ''; - display: block; - padding-top: calc(#{$height} / #{$width} * 100%); - } - } -} - -// INTERACTIVE STATES @mixin hover { @media (hover: hover) and (pointer: fine) { &:hover { @@ -318,25 +110,11 @@ $breakpoints: ( } } -@mixin active { - &:active { - @content; - } -} - -@mixin hover-active { - @include hover { - @content; - } - - @include active { - @content; - } -} +@mixin hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; -// LAYER MANAGEMENT (CSS Cascade Layers) -@mixin layer($name) { - @layer #{$name} { - @content; + &::-webkit-scrollbar { + display: none; } } diff --git a/frontend/src/styles/_reset.scss b/frontend/src/styles/_reset.scss index 4d4cdd0..b6af56b 100644 --- a/frontend/src/styles/_reset.scss +++ b/frontend/src/styles/_reset.scss @@ -3,6 +3,9 @@ // _reset.scss // =================== +@use 'tokens' as *; +@use 'fonts' as *; + *, *::before, *::after { @@ -33,11 +36,14 @@ html { body { min-height: 100vh; min-height: 100dvh; - line-height: 1.5; + line-height: $line-height-normal; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; overflow-x: hidden; + background-color: $bg-default; + color: $text-default; + font-family: $font-sans; } h1, @@ -46,9 +52,10 @@ h3, h4, h5, h6 { - line-height: 1.1; + line-height: $line-height-tight; text-wrap: balance; overflow-wrap: break-word; + font-weight: $font-weight-semibold; } p { @@ -61,20 +68,11 @@ ol { list-style: none; } -ul[role='list'], -ol[role='list'] { - list-style: none; -} - a { color: inherit; text-decoration: none; } -a:not([class]) { - text-decoration-skip-ink: auto; -} - img, picture, video, @@ -102,7 +100,7 @@ input[type='tel'], input[type='url'], textarea, select { - font-size: 1rem; + font-size: $font-size-sm; appearance: none; } @@ -132,7 +130,7 @@ textarea:not([rows]) { } :focus-visible { - outline: 2px solid currentColor; + outline: 2px solid $border-strong; outline-offset: 2px; } @@ -146,7 +144,7 @@ textarea:not([rows]) { [disabled] { cursor: not-allowed; - opacity: 0.6; + opacity: 0.5; } dialog { @@ -159,10 +157,6 @@ summary { cursor: pointer; } -progress { - vertical-align: baseline; -} - @media (prefers-reduced-motion: reduce) { *, *::before, @@ -182,8 +176,8 @@ progress { } ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 6px; + height: 6px; } ::-webkit-scrollbar-track { @@ -191,14 +185,14 @@ progress { } ::-webkit-scrollbar-thumb { - background: rgb(255 255 255 / 20%); - border-radius: 4px; + background: $border-default; + border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background: rgb(255 255 255 / 30%); + background: $border-strong; } ::selection { - background-color: rgb(255 255 255 / 20%); + background-color: $bg-selection; } diff --git a/frontend/src/styles/_tokens.scss b/frontend/src/styles/_tokens.scss index 14838b7..be1f6dd 100644 --- a/frontend/src/styles/_tokens.scss +++ b/frontend/src/styles/_tokens.scss @@ -8,81 +8,59 @@ // ============================================================================ $space-0: 0; $space-px: 1px; -$space-0-5: 0.125rem; // 2px -$space-1: 0.25rem; // 4px -$space-1-5: 0.375rem; // 6px -$space-2: 0.5rem; // 8px -$space-2-5: 0.625rem; // 10px -$space-3: 0.75rem; // 12px -$space-3-5: 0.875rem; // 14px -$space-4: 1rem; // 16px -$space-5: 1.25rem; // 20px -$space-6: 1.5rem; // 24px -$space-7: 1.75rem; // 28px -$space-8: 2rem; // 32px -$space-9: 2.25rem; // 36px -$space-10: 2.5rem; // 40px -$space-11: 2.75rem; // 44px -$space-12: 3rem; // 48px -$space-14: 3.5rem; // 56px -$space-16: 4rem; // 64px -$space-20: 5rem; // 80px -$space-24: 6rem; // 96px -$space-28: 7rem; // 112px -$space-32: 8rem; // 128px -$space-36: 9rem; // 144px -$space-40: 10rem; // 160px -$space-44: 11rem; // 176px -$space-48: 12rem; // 192px -$space-52: 13rem; // 208px -$space-56: 14rem; // 224px -$space-60: 15rem; // 240px -$space-64: 16rem; // 256px -$space-72: 18rem; // 288px -$space-80: 20rem; // 320px -$space-96: 24rem; // 384px - -// ============================================================================ -// TYPOGRAPHY SCALE (1.25 ratio - Major Third) -// ============================================================================ -$font-size-3xs: 0.512rem; // ~8px -$font-size-2xs: 0.64rem; // ~10px -$font-size-xs: 0.75rem; // 12px -$font-size-sm: 0.875rem; // 14px -$font-size-base: 1rem; // 16px -$font-size-md: 1.125rem; // 18px -$font-size-lg: 1.25rem; // 20px -$font-size-xl: 1.5rem; // 24px -$font-size-2xl: 1.875rem; // 30px -$font-size-3xl: 2.25rem; // 36px -$font-size-4xl: 3rem; // 48px -$font-size-5xl: 3.75rem; // 60px -$font-size-6xl: 4.5rem; // 72px -$font-size-7xl: 6rem; // 96px -$font-size-8xl: 8rem; // 128px +$space-0-5: 0.125rem; +$space-1: 0.25rem; +$space-1-5: 0.375rem; +$space-2: 0.5rem; +$space-2-5: 0.625rem; +$space-3: 0.75rem; +$space-3-5: 0.875rem; +$space-4: 1rem; +$space-5: 1.25rem; +$space-6: 1.5rem; +$space-7: 1.75rem; +$space-8: 2rem; +$space-9: 2.25rem; +$space-10: 2.5rem; +$space-11: 2.75rem; +$space-12: 3rem; +$space-14: 3.5rem; +$space-16: 4rem; +$space-20: 5rem; +$space-24: 6rem; +$space-28: 7rem; +$space-32: 8rem; + +// ============================================================================ +// TYPOGRAPHY SCALE +// ============================================================================ +$font-size-3xs: 0.625rem; +$font-size-2xs: 0.6875rem; +$font-size-xs: 0.75rem; +$font-size-sm: 0.875rem; +$font-size-base: 1rem; +$font-size-lg: 1.125rem; +$font-size-xl: 1.25rem; +$font-size-2xl: 1.5rem; +$font-size-3xl: 1.875rem; +$font-size-4xl: 2.25rem; +$font-size-5xl: 3rem; // ============================================================================ // FONT WEIGHTS // ============================================================================ -$font-weight-thin: 100; -$font-weight-extralight: 200; -$font-weight-light: 300; $font-weight-regular: 400; $font-weight-medium: 500; $font-weight-semibold: 600; -$font-weight-bold: 700; -$font-weight-extrabold: 800; -$font-weight-black: 900; // ============================================================================ // LINE HEIGHTS // ============================================================================ $line-height-none: 1; -$line-height-tight: 1.1; -$line-height-snug: 1.25; +$line-height-tight: 1.2; +$line-height-snug: 1.375; $line-height-normal: 1.5; $line-height-relaxed: 1.625; -$line-height-loose: 2; // ============================================================================ // LETTER SPACING @@ -92,131 +70,56 @@ $tracking-tight: -0.025em; $tracking-normal: 0; $tracking-wide: 0.025em; $tracking-wider: 0.05em; -$tracking-widest: 0.1em; // ============================================================================ -// COLORS (OKLCH - Modern Color Space) +// COLORS // ============================================================================ +$white: hsl(0, 0%, 100%); +$black: hsl(0, 0%, 0%); -// Neutrals (grayscale) -$gray-50: oklch(98% 0 0deg); -$gray-100: oklch(95% 0 0deg); -$gray-200: oklch(90% 0 0deg); -$gray-300: oklch(80% 0 0deg); -$gray-400: oklch(65% 0 0deg); -$gray-500: oklch(50% 0 0deg); -$gray-600: oklch(40% 0 0deg); -$gray-700: oklch(30% 0 0deg); -$gray-800: oklch(20% 0 0deg); -$gray-900: oklch(12% 0 0deg); -$gray-950: oklch(8% 0 0deg); +// Auth +$bg-page: hsl(0, 0%, 10.5%); +$bg-card: hsl(0, 0%, 6.2%); -// Primary (Blue) -$primary-50: oklch(97% 0.02 250deg); -$primary-100: oklch(93% 0.04 250deg); -$primary-200: oklch(87% 0.08 250deg); -$primary-300: oklch(78% 0.12 250deg); -$primary-400: oklch(68% 0.16 250deg); -$primary-500: oklch(58% 0.2 250deg); -$primary-600: oklch(50% 0.2 250deg); -$primary-700: oklch(42% 0.18 250deg); -$primary-800: oklch(35% 0.15 250deg); -$primary-900: oklch(28% 0.12 250deg); -$primary-950: oklch(20% 0.08 250deg); +// Home/landing +$bg-landing: hsl(0, 0%, 10.8%); -// Success (Green) -$success-50: oklch(97% 0.03 145deg); -$success-100: oklch(93% 0.06 145deg); -$success-200: oklch(87% 0.1 145deg); -$success-300: oklch(78% 0.14 145deg); -$success-400: oklch(68% 0.18 145deg); -$success-500: oklch(58% 0.18 145deg); -$success-600: oklch(50% 0.16 145deg); -$success-700: oklch(42% 0.14 145deg); -$success-800: oklch(35% 0.12 145deg); -$success-900: oklch(28% 0.1 145deg); +$bg-default: hsl(0, 0%, 7.1%); +$bg-alternative: hsl(0, 0%, 5.9%); +$bg-surface-75: hsl(0, 0%, 9%); +$bg-surface-100: hsl(0, 0%, 12.2%); +$bg-surface-200: hsl(0, 0%, 14.1%); +$bg-surface-300: hsl(0, 0%, 16.1%); +$bg-control: hsl(0, 0%, 10%); +$bg-selection: hsl(0, 0%, 19.2%); +$bg-overlay: hsl(0, 0%, 14.1%); +$bg-overlay-hover: hsl(0, 0%, 18%); -// Warning (Yellow/Orange) -$warning-50: oklch(98% 0.03 85deg); -$warning-100: oklch(95% 0.06 85deg); -$warning-200: oklch(90% 0.1 85deg); -$warning-300: oklch(85% 0.14 85deg); -$warning-400: oklch(78% 0.18 80deg); -$warning-500: oklch(72% 0.18 75deg); -$warning-600: oklch(62% 0.16 70deg); -$warning-700: oklch(52% 0.14 65deg); -$warning-800: oklch(42% 0.12 60deg); -$warning-900: oklch(32% 0.1 55deg); +$border-muted: hsl(0, 0%, 11.1%); +$border-default: hsl(0, 0%, 18%); +$border-strong: hsl(0, 0%, 22.4%); +$border-stronger: hsl(0, 0%, 27.1%); +$border-control: hsl(0, 0%, 22.4%); -// Error (Red) -$error-50: oklch(97% 0.02 25deg); -$error-100: oklch(93% 0.04 25deg); -$error-200: oklch(87% 0.08 25deg); -$error-300: oklch(78% 0.14 25deg); -$error-400: oklch(65% 0.2 25deg); -$error-500: oklch(55% 0.22 25deg); -$error-600: oklch(48% 0.2 25deg); -$error-700: oklch(40% 0.18 25deg); -$error-800: oklch(33% 0.14 25deg); -$error-900: oklch(25% 0.1 25deg); +$text-default: hsl(0, 0%, 98%); +$text-light: hsl(0, 0%, 70.6%); +$text-lighter: hsl(0, 0%, 53.7%); +$text-muted: hsl(0, 0%, 30.2%); -// Info (Cyan) -$info-50: oklch(97% 0.02 200deg); -$info-100: oklch(93% 0.04 200deg); -$info-200: oklch(87% 0.08 200deg); -$info-300: oklch(78% 0.12 200deg); -$info-400: oklch(68% 0.16 200deg); -$info-500: oklch(58% 0.18 200deg); -$info-600: oklch(50% 0.16 200deg); -$info-700: oklch(42% 0.14 200deg); -$info-800: oklch(35% 0.12 200deg); -$info-900: oklch(28% 0.1 200deg); - -// ============================================================================ -// SEMANTIC COLORS (Light/Dark aware via CSS custom properties) -// ============================================================================ -:root { - --color-bg: #{$gray-950}; - --color-bg-subtle: #{$gray-900}; - --color-bg-muted: #{$gray-800}; - --color-surface: #{$gray-900}; - --color-surface-raised: #{$gray-800}; - --color-border: #{$gray-800}; - --color-border-subtle: #{$gray-700}; - --color-text: #{$gray-50}; - --color-text-muted: #{$gray-400}; - --color-text-subtle: #{$gray-500}; - --color-primary: #{$primary-500}; - --color-primary-hover: #{$primary-400}; - --color-success: #{$success-500}; - --color-warning: #{$warning-500}; - --color-error: #{$error-500}; - --color-info: #{$info-500}; -} +$error-default: hsl(0, 72%, 51%); +$error-light: hsl(0, 72%, 65%); // ============================================================================ // BORDER RADIUS // ============================================================================ $radius-none: 0; -$radius-sm: 0.125rem; // 2px -$radius-md: 0.25rem; // 4px -$radius-lg: 0.5rem; // 8px -$radius-xl: 0.75rem; // 12px -$radius-2xl: 1rem; // 16px -$radius-3xl: 1.5rem; // 24px +$radius-xs: 2px; +$radius-sm: 4px; +$radius-md: 6px; +$radius-lg: 8px; +$radius-xl: 12px; $radius-full: 9999px; -// ============================================================================ -// SHADOWS -// ============================================================================ -$shadow-xs: 0 1px 2px 0 oklch(0% 0 0deg / 5%); -$shadow-sm: 0 1px 3px 0 oklch(0% 0 0deg / 10%), 0 1px 2px -1px oklch(0% 0 0deg / 10%); -$shadow-md: 0 4px 6px -1px oklch(0% 0 0deg / 10%), 0 2px 4px -2px oklch(0% 0 0deg / 10%); -$shadow-lg: 0 10px 15px -3px oklch(0% 0 0deg / 10%), 0 4px 6px -4px oklch(0% 0 0deg / 10%); -$shadow-xl: 0 20px 25px -5px oklch(0% 0 0deg / 10%), 0 8px 10px -6px oklch(0% 0 0deg / 10%); -$shadow-2xl: 0 25px 50px -12px oklch(0% 0 0deg / 25%); -$shadow-inner: inset 0 2px 4px 0 oklch(0% 0 0deg / 5%); - // ============================================================================ // Z-INDEX SCALE // ============================================================================ @@ -237,20 +140,14 @@ $z-max: 9999; // ============================================================================ $duration-instant: 0ms; $duration-fast: 100ms; -$duration-normal: 200ms; -$duration-slow: 300ms; -$duration-slower: 500ms; -$duration-slowest: 700ms; +$duration-normal: 150ms; +$duration-slow: 200ms; -$ease-linear: linear; -$ease-in: cubic-bezier(0.4, 0, 1, 1); $ease-out: cubic-bezier(0, 0, 0.2, 1); $ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); -$ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); -$ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55); // ============================================================================ -// BREAKPOINTS (SCSS only - can't use in CSS custom properties) +// BREAKPOINTS // ============================================================================ $breakpoint-xs: 360px; $breakpoint-sm: 480px; @@ -258,20 +155,14 @@ $breakpoint-md: 768px; $breakpoint-lg: 1024px; $breakpoint-xl: 1280px; $breakpoint-2xl: 1536px; -$breakpoint-3xl: 1920px; // ============================================================================ // CONTAINER WIDTHS // ============================================================================ -$container-xs: 20rem; // 320px -$container-sm: 24rem; // 384px -$container-md: 28rem; // 448px -$container-lg: 32rem; // 512px -$container-xl: 36rem; // 576px -$container-2xl: 42rem; // 672px -$container-3xl: 48rem; // 768px -$container-4xl: 56rem; // 896px -$container-5xl: 64rem; // 1024px -$container-6xl: 72rem; // 1152px -$container-7xl: 80rem; // 1280px +$container-xs: 20rem; +$container-sm: 24rem; +$container-md: 28rem; +$container-lg: 32rem; +$container-xl: 36rem; +$container-2xl: 42rem; $container-full: 100%; diff --git a/frontend/stylelint.config.js b/frontend/stylelint.config.js index 987c49d..56ebd7b 100755 --- a/frontend/stylelint.config.js +++ b/frontend/stylelint.config.js @@ -76,6 +76,16 @@ export default { 'custom-property-empty-line-before': null, 'no-descending-specificity': null, + + 'media-feature-name-no-unknown': [ + true, + { + ignoreMediaFeatureNames: ['map'], + }, + ], + + 'color-function-notation': null, + 'hue-degree-notation': null, }, ignoreFiles: [ 'node_modules/**', diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f96a39c..a8d5d47 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -23,9 +23,7 @@ export default defineConfig(({ mode }) => { css: { preprocessorOptions: { - scss: { - api: 'modern-compiler', - }, + scss: {}, }, }, @@ -42,15 +40,25 @@ export default defineConfig(({ mode }) => { }, build: { - target: 'ES2022', + target: 'esnext', + cssTarget: 'chrome100', sourcemap: isDev ? true : 'hidden', - minify: 'esbuild', + minify: 'oxc', rollupOptions: { output: { - manualChunks: { - 'vendor-react': ['react', 'react-dom', 'react-router-dom'], - 'vendor-query': ['@tanstack/react-query'], - 'vendor-state': ['zustand'], + manualChunks(id: string): string | undefined { + if (id.includes('node_modules')) { + if (id.includes('react-dom') || id.includes('react-router')) { + return 'vendor-react' + } + if (id.includes('@tanstack/react-query')) { + return 'vendor-query' + } + if (id.includes('zustand')) { + return 'vendor-state' + } + } + return undefined }, }, }, diff --git a/infra/docker/fastapi.dev b/infra/docker/fastapi.dev index 9adbf65..2df53f1 100644 --- a/infra/docker/fastapi.dev +++ b/infra/docker/fastapi.dev @@ -31,4 +31,4 @@ RUN --mount=type=cache,target=/root/.cache/uv \ EXPOSE 8000 -CMD ["sh", "-c", "uv run alembic upgrade head && uv run uvicorn __main__:app --host 0.0.0.0 --port 8000 --reload"] +CMD ["sh", "-c", "uv run alembic upgrade head && uv run uvicorn app.__main__:app --host 0.0.0.0 --port 8000 --reload"] diff --git a/infra/docker/fastapi.prod b/infra/docker/fastapi.prod index 363f993..b7b7ad6 100644 --- a/infra/docker/fastapi.prod +++ b/infra/docker/fastapi.prod @@ -56,4 +56,4 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 -CMD ["sh", "-c", "alembic upgrade head && gunicorn __main__:app --worker-class uvicorn.workers.UvicornWorker --workers 4 --bind 0.0.0.0:8000 --max-requests 1000 --max-requests-jitter 100 --access-logfile - --error-logfile -"] +CMD ["sh", "-c", "alembic upgrade head && gunicorn app.__main__:app --worker-class uvicorn.workers.UvicornWorker --workers 4 --bind 0.0.0.0:8000 --max-requests 1000 --max-requests-jitter 100 --access-logfile - --error-logfile -"] diff --git a/infra/docker/frontend-builder.prod b/infra/docker/frontend-builder.prod index a9f50d3..5aecf69 100644 --- a/infra/docker/frontend-builder.prod +++ b/infra/docker/frontend-builder.prod @@ -41,8 +41,8 @@ RUN rm -rf /usr/share/nginx/html/* && \ COPY --from=builder /app/dist /usr/share/nginx/html -COPY infra/nginx/nginx.conf /etc/nginx/nginx.conf -COPY infra/nginx/prod.nginx /etc/nginx/conf.d/default.conf +COPY --chown=nginx:nginx infra/nginx/nginx.prod.conf /etc/nginx/nginx.conf +COPY --chown=nginx:nginx infra/nginx/prod.nginx /etc/nginx/conf.d/default.conf RUN chown -R nginx:nginx /usr/share/nginx/html && \ chown -R nginx:nginx /var/cache/nginx && \ diff --git a/infra/nginx/nginx.prod.conf b/infra/nginx/nginx.prod.conf new file mode 100644 index 0000000..644b74d --- /dev/null +++ b/infra/nginx/nginx.prod.conf @@ -0,0 +1,81 @@ +# AngelaMos | 2025 +# nginx.prod.conf +# Production nginx configuration (no dev frontend upstream) + +worker_processes auto; +worker_rlimit_nofile 65535; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 4096; + multi_accept on; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # WebSocket upgrade handling + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream backend { + server backend:8000 max_fails=3 fail_timeout=30s; + keepalive 32; + keepalive_requests 1000; + keepalive_timeout 60s; + } + + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=1r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + limit_req_status 429; + + log_format main_timed '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + # Buffer sizes + client_body_buffer_size 128k; + client_header_buffer_size 16k; + client_max_body_size 10m; + large_client_header_buffers 4 16k; + + client_body_timeout 12s; + client_header_timeout 12s; + send_timeout 10s; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + gzip_disable "msie6"; + + # Include server blocks + include /etc/nginx/conf.d/*.conf; +}