Skip to content

Commit 05847ee

Browse files
official completion
1 parent 20a2755 commit 05847ee

File tree

104 files changed

+5696
-2582
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

104 files changed

+5696
-2582
lines changed

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ JWT_ALGORITHM=HS256
6464
ACCESS_TOKEN_EXPIRE_MINUTES=15
6565
REFRESH_TOKEN_EXPIRE_DAYS=7
6666

67+
# =============================================================================
68+
# Admin Bootstrap (optional)
69+
# =============================================================================
70+
# If set, registering with this email auto-promotes to admin role.
71+
# Leave empty or remove to disable auto-admin (all users register as USER).
72+
ADMIN_EMAIL=
73+
6774
# =============================================================================
6875
# CORS (must match HOST PORTS above!)
6976
# =============================================================================
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""initial
2+
3+
Revision ID: 801b86be184b
4+
Revises:
5+
Create Date: 2025-12-24 03:31:04.712921
6+
"""
7+
from typing import Sequence, Union
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
revision: str = '801b86be184b'
14+
down_revision: Union[str, None] = None
15+
branch_labels: Union[str, Sequence[str], None] = None
16+
depends_on: Union[str, Sequence[str], None] = None
17+
18+
19+
def upgrade() -> None:
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.create_table('users',
22+
sa.Column('email', sa.String(length=320), nullable=False),
23+
sa.Column('hashed_password', sa.String(length=1024), nullable=False),
24+
sa.Column('full_name', sa.String(length=255), nullable=True),
25+
sa.Column('is_active', sa.Boolean(), nullable=False),
26+
sa.Column('is_verified', sa.Boolean(), nullable=False),
27+
sa.Column('role', sa.Enum('unknown', 'user', 'admin', name='userrole'), nullable=False),
28+
sa.Column('token_version', sa.Integer(), nullable=False),
29+
sa.Column('id', sa.Uuid(), nullable=False),
30+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
31+
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
32+
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
33+
)
34+
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
35+
op.create_table('refresh_tokens',
36+
sa.Column('token_hash', sa.String(length=64), nullable=False),
37+
sa.Column('user_id', sa.Uuid(), nullable=False),
38+
sa.Column('family_id', sa.Uuid(), nullable=False),
39+
sa.Column('device_id', sa.String(length=255), nullable=True),
40+
sa.Column('device_name', sa.String(length=100), nullable=True),
41+
sa.Column('ip_address', sa.String(length=45), nullable=True),
42+
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
43+
sa.Column('is_revoked', sa.Boolean(), nullable=False),
44+
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
45+
sa.Column('id', sa.Uuid(), nullable=False),
46+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
47+
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
48+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_refresh_tokens_user_id_users'), ondelete='CASCADE'),
49+
sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_tokens'))
50+
)
51+
op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False)
52+
op.create_index(op.f('ix_refresh_tokens_family_id'), 'refresh_tokens', ['family_id'], unique=False)
53+
op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=True)
54+
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
55+
# ### end Alembic commands ###
56+
57+
58+
def downgrade() -> None:
59+
# ### commands auto generated by Alembic - please adjust! ###
60+
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
61+
op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens')
62+
op.drop_index(op.f('ix_refresh_tokens_family_id'), table_name='refresh_tokens')
63+
op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens')
64+
op.drop_table('refresh_tokens')
65+
op.drop_index(op.f('ix_users_email'), table_name='users')
66+
op.drop_table('users')
67+
# ### end Alembic commands ###

backend/alembic/versions/2025_12_07_001_initial_schema.py

Lines changed: 0 additions & 28 deletions
This file was deleted.

backend/app/__init__.py

Lines changed: 0 additions & 24 deletions
This file was deleted.

backend/app/__main__.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
ⒸAngelaMos | 2025
33
__main__.py
44
"""
5+
56
import uvicorn
67

78
from config import settings
@@ -12,8 +13,30 @@
1213

1314
if __name__ == "__main__":
1415
uvicorn.run(
15-
"__main__:app",
16+
"app.__main__:app",
1617
host = settings.HOST,
1718
port = settings.PORT,
1819
reload = settings.RELOAD,
1920
)
21+
"""
22+
23+
⠀⠀⠀⠀⠀⠴⣦⣤⡀⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
24+
⠀⠀⠀⠀⠀⠀⠀⣨⣥⣄⣀⠀⡁⠀⠀⡀⡠⠀⠀⠀⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
25+
⠀⠀⠀⠀⠀⠀⢠⣾⣿⣷⣮⣷⡦⠥⠈⡶⠮⣤⣀⡠⠀⡀⣐⣀⡈⠁⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
26+
⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⠟⠀⠠⠊⠉⠀⠀⢀⠉⠙⠚⠧⣦⣀⡀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
27+
⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠠⠀⠁⠀⢤⠀⠀⠀⠨⡉⠛⠶⠤⣄⣄⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
28+
⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⡀⠀⠀⢰⠀⠍⡾⠆⠀⠀⣠⡦⠄⡀⠄⠀⠠⠀⠀⠀⠈⠙⠓⠦⢤⣀⡀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
29+
⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣶⣦⢠⡈⠀⠀⠀⠀⠀⠋⠛⠉⡂⠈⠙⠀⣰⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠺⠦⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
30+
⠀⠀⠀⠀⠀⠀⠻⢿⣿⣿⣿⣿⣿⣾⣿⣿⣦⢤⡀⢀⣂⣨⠀⢅⢱⡔⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠲⠴⣠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
31+
⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣎⠘⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
32+
⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣾⣽⡿⢿⣿⣿⣿⣿⣿⣿⣿⣿⠳⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
33+
⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠏⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠹⣦⣴⠖⠲⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
34+
⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠈⠀⠀⠀⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
35+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢩⠢⣙⠿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
36+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣆⠈⠛⢶⣌⡉⣻⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
37+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣷⣄⣤⣙⣿⣿⣿⣷⣄⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
38+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠟⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
39+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⣿⣿⣿⣿⣿⣿⡿⠋⠉⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
40+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠙⠁⠘⢮⣛⡽⠛⠿⡿⠥⠀
41+
42+
"""

backend/app/admin/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""
2+
ⒸAngelaMos | 2025
3+
Admin Domain
4+
"""

backend/app/auth/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""
2+
ⒸAngelaMos | 2025
3+
Auth Domain
4+
"""

backend/app/auth/routes.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ async def login(
7474
responses = {**AUTH_401}
7575
)
7676
async def refresh_token(
77+
response: Response,
7778
auth_service: AuthServiceDep,
7879
ip: ClientIP,
7980
refresh_token: str | None = Cookie(None),
@@ -83,10 +84,12 @@ async def refresh_token(
8384
"""
8485
if not refresh_token:
8586
raise TokenError("Refresh token required")
86-
return await auth_service.refresh_tokens(
87+
result, new_refresh_token = await auth_service.refresh_tokens(
8788
refresh_token,
8889
ip_address = ip
8990
)
91+
set_refresh_cookie(response, new_refresh_token)
92+
return result
9093

9194

9295
@router.post(

backend/app/auth/service.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ async def authenticate(
6363
raise InvalidCredentials()
6464

6565
if new_hash:
66-
await UserRepository.update_password(self.session, user, new_hash)
66+
await UserRepository.update_password(
67+
self.session,
68+
user,
69+
new_hash
70+
)
6771

6872
access_token = create_access_token(user.id, user.token_version)
6973

@@ -115,7 +119,7 @@ async def refresh_tokens(
115119
device_id: str | None = None,
116120
device_name: str | None = None,
117121
ip_address: str | None = None,
118-
) -> TokenResponse:
122+
) -> tuple[TokenResponse, str]:
119123
"""
120124
Refresh access token using refresh token
121125
@@ -147,11 +151,14 @@ async def refresh_tokens(
147151
if user is None or not user.is_active:
148152
raise TokenError(message = "User not found or inactive")
149153

150-
await RefreshTokenRepository.revoke_token(self.session, stored_token)
154+
await RefreshTokenRepository.revoke_token(
155+
self.session,
156+
stored_token
157+
)
151158

152159
access_token = create_access_token(user.id, user.token_version)
153160

154-
_, new_hash, expires_at = create_refresh_token(
161+
new_raw_token, new_hash, expires_at = create_refresh_token(
155162
user.id, stored_token.family_id
156163
)
157164

@@ -166,7 +173,7 @@ async def refresh_tokens(
166173
ip_address = ip_address,
167174
)
168175

169-
return TokenResponse(access_token = access_token)
176+
return TokenResponse(access_token = access_token), new_raw_token
170177

171178
async def logout(
172179
self,

backend/app/config.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from functools import lru_cache
99

1010
from pydantic import (
11+
EmailStr,
1112
Field,
1213
RedisDsn,
1314
SecretStr,
@@ -80,9 +81,9 @@ class Settings(BaseSettings):
8081

8182
APP_NAME: str = "FastAPI Template"
8283
APP_VERSION: str = "1.0.0"
83-
APP_SUMMARY: str = "FastAPI Backend Template"
84-
APP_DESCRIPTION: str = "Async first boilerplate - JWT, Asyncdb, PostgreSQL"
85-
APP_CONTACT_NAME: str = "AngelaMos"
84+
APP_SUMMARY: str = "Developed CarterPerez-dev"
85+
APP_DESCRIPTION: str = "FastAPI async first boilerplate - JWT, Asyncdb, PostgreSQL"
86+
APP_CONTACT_NAME: str = "AngelaMos LLC"
8687
APP_CONTACT_EMAIL: str = "[email protected]"
8788
APP_LICENSE_NAME: str = "MIT"
8889
APP_LICENSE_URL: str = "https://github.com/CarterPerez-dev/fullstack-template/docs/templates/MIT"
@@ -105,6 +106,8 @@ class Settings(BaseSettings):
105106
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default = 15, ge = 5, le = 60)
106107
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default = 7, ge = 1, le = 30)
107108

109+
ADMIN_EMAIL: EmailStr | None = None
110+
108111
REDIS_URL: RedisDsn | None = None
109112

110113
CORS_ORIGINS: list[str] = [

0 commit comments

Comments
 (0)