diff --git a/.env b/.env index 1d44286e25..b1f8064353 100644 --- a/.env +++ b/.env @@ -6,7 +6,7 @@ DOMAIN=localhost # DOMAIN=localhost.tiangolo.com # Used by the backend to generate links in emails to the frontend -FRONTEND_HOST=http://localhost:5173 +FRONTEND_HOST=http://localhost:5174 # In staging and production, set this env var to the frontend host, e.g. # FRONTEND_HOST=https://dashboard.example.com @@ -17,7 +17,7 @@ PROJECT_NAME="Full Stack FastAPI Project" STACK_NAME=full-stack-fastapi-project # Backend -BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" +BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5174,https://localhost,https://localhost:5174,http://localhost.tiangolo.com" SECRET_KEY=changethis FIRST_SUPERUSER=admin@example.com FIRST_SUPERUSER_PASSWORD=changethis diff --git a/backend/Dockerfile b/backend/Dockerfile index 9f31dcd78a..765df6ff6d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,9 +22,9 @@ ENV PATH="/app/.venv/bin:$PATH" # Install dependencies # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers +COPY uv.lock pyproject.toml /app/ + RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-install-workspace --package app COPY ./backend/scripts /app/backend/scripts @@ -36,8 +36,6 @@ COPY ./backend/app /app/backend/app # Sync the project # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --package app WORKDIR /app/backend/ diff --git a/backend/app/alembic/versions/a1b2c3d4e5f6_add_avatar_url_to_user.py b/backend/app/alembic/versions/a1b2c3d4e5f6_add_avatar_url_to_user.py new file mode 100644 index 0000000000..bfc25b44fb --- /dev/null +++ b/backend/app/alembic/versions/a1b2c3d4e5f6_add_avatar_url_to_user.py @@ -0,0 +1,25 @@ +"""Add avatar_url to User + +Revision ID: a1b2c3d4e5f6 +Revises: fe56fa70289e +Create Date: 2026-02-05 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = 'fe56fa70289e' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('user', sa.Column('avatar_url', sa.String(length=500), nullable=True)) + + +def downgrade(): + op.drop_column('user', 'avatar_url') diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 35f64b626e..6fb83eaee0 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,7 +1,9 @@ +import shutil import uuid +from pathlib import Path from typing import Any -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from sqlmodel import col, delete, func, select from app import crud @@ -99,6 +101,35 @@ def update_user_me( return current_user +@router.post("/me/avatar", response_model=UserPublic) +def update_user_avatar( + *, session: SessionDep, current_user: CurrentUser, file: UploadFile = File(...) +) -> Any: + """ + Upload user avatar. + """ + # Ensure upload directory exists + upload_dir = Path("app/static/uploads") + upload_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_ext = Path(file.filename).suffix if file.filename else "" + file_name = f"{current_user.id}_{uuid.uuid4()}{file_ext}" + file_path = upload_dir / file_name + + with file_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + # Update user profile + # Assuming served at /static/uploads/ + avatar_url = f"/static/uploads/{file_name}" + current_user.avatar_url = avatar_url + session.add(current_user) + session.commit() + session.refresh(current_user) + return current_user + + @router.patch("/me/password", response_model=Message) def update_password_me( *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7910..56c71fa5d5 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -34,7 +34,7 @@ class Settings(BaseSettings): SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 8 days = 8 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 - FRONTEND_HOST: str = "http://localhost:5173" + FRONTEND_HOST: str = "http://localhost:5174" ENVIRONMENT: Literal["local", "staging", "production"] = "local" BACKEND_CORS_ORIGINS: Annotated[ diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e74..041a166a6f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,9 @@ +import os + import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute +from fastapi.staticfiles import StaticFiles from starlette.middleware.cors import CORSMiddleware from app.api.main import api_router @@ -30,4 +33,11 @@ def custom_generate_unique_id(route: APIRoute) -> str: allow_headers=["*"], ) +# Mount static files +upload_dir = "app/static" +if not os.path.exists(upload_dir): + os.makedirs(upload_dir) + +app.mount("/static", StaticFiles(directory=upload_dir), name="static") + app.include_router(api_router, prefix=settings.API_V1_STR) diff --git a/backend/app/models.py b/backend/app/models.py index b5132e0e2c..c4635c92c1 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -16,6 +16,7 @@ class UserBase(SQLModel): is_active: bool = True is_superuser: bool = False full_name: str | None = Field(default=None, max_length=255) + avatar_url: str | None = Field(default=None, max_length=500) # Properties to receive via API on creation diff --git a/backend/app/static/uploads/5eaaec5c-84d0-480a-958c-d786840b12b8_c2917da5-1ca2-48bb-b8e9-5bb2ee73f909.JPG b/backend/app/static/uploads/5eaaec5c-84d0-480a-958c-d786840b12b8_c2917da5-1ca2-48bb-b8e9-5bb2ee73f909.JPG new file mode 100644 index 0000000000..7d26d796b7 Binary files /dev/null and b/backend/app/static/uploads/5eaaec5c-84d0-480a-958c-d786840b12b8_c2917da5-1ca2-48bb-b8e9-5bb2ee73f909.JPG differ diff --git a/compose.override.yml b/compose.override.yml index 779cc8238d..36d3a2e454 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -10,8 +10,8 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock ports: - - "80:80" - - "8090:8080" + - "81:80" + - "8091:8080" # Duplicate the command from compose.yml to add --api.insecure=true command: # Enable Docker in Traefik, so that it reads labels from Docker services @@ -48,17 +48,17 @@ services: db: restart: "no" ports: - - "5432:5432" + - "5433:5432" adminer: restart: "no" ports: - - "8080:8080" + - "8082:8080" backend: restart: "no" ports: - - "8000:8000" + - "8001:8000" build: context: . dockerfile: backend/Dockerfile @@ -81,6 +81,7 @@ services: # TODO: remove once coverage is done locally volumes: - ./backend/htmlcov:/app/backend/htmlcov + - ./backend/app/static:/app/backend/app/static environment: SMTP_HOST: "mailcatcher" SMTP_PORT: "1025" @@ -90,18 +91,18 @@ services: mailcatcher: image: schickling/mailcatcher ports: - - "1080:1080" - - "1025:1025" + - "1081:1080" + - "1026:1025" frontend: restart: "no" ports: - - "5173:80" + - "5174:80" build: context: . dockerfile: frontend/Dockerfile args: - - VITE_API_URL=http://localhost:8000 + - VITE_API_URL=http://localhost:8001 - NODE_ENV=development playwright: @@ -127,7 +128,7 @@ services: - ./frontend/blob-report:/app/frontend/blob-report - ./frontend/test-results:/app/frontend/test-results ports: - - 9323:9323 + - 9324:9323 networks: traefik-public: diff --git a/compose.traefik.yml b/compose.traefik.yml index bcd7d142ca..8c0a333c1d 100644 --- a/compose.traefik.yml +++ b/compose.traefik.yml @@ -3,9 +3,9 @@ services: image: traefik:3.6 ports: # Listen on port 80, default for HTTP, necessary to redirect to HTTPS - - 80:80 + - 81:80 # Listen on port 443, default for HTTPS - - 443:443 + - 444:443 restart: always labels: # Enable Traefik for this service, to make it available in the public network diff --git a/compose.yml b/compose.yml index 2488fc007b..5dae6f3848 100644 --- a/compose.yml +++ b/compose.yml @@ -11,6 +11,7 @@ services: timeout: 10s volumes: - app-db-data:/var/lib/postgresql/data/pgdata + - app-static-data:/app/backend/app/static env_file: - .env environment: @@ -115,6 +116,9 @@ services: timeout: 5s retries: 5 + volumes: + - app-static-data:/app/backend/app/static + build: context: . dockerfile: backend/Dockerfile @@ -167,6 +171,7 @@ services: - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect volumes: app-db-data: + app-static-data: networks: traefik-public: diff --git a/development.md b/development.md index 7879ffcdbc..15b43caa93 100644 --- a/development.md +++ b/development.md @@ -10,15 +10,15 @@ docker compose watch * Now you can open your browser and interact with these URLs: -Frontend, built with Docker, with routes handled based on the path: +Frontend, built with Docker, with routes handled based on the path: -Backend, JSON based web API based on OpenAPI: +Backend, JSON based web API based on OpenAPI: -Automatic interactive documentation with Swagger UI (from the OpenAPI backend): +Automatic interactive documentation with Swagger UI (from the OpenAPI backend): -Adminer, database web administration: +Adminer, database web administration: -Traefik UI, to see how the routes are being handled by the proxy: +Traefik UI, to see how the routes are being handled by the proxy: **Note**: The first time you start your stack, it might take a minute for it to be ready. While the backend waits for the database to be ready and configures everything. You can check the logs to monitor it. @@ -44,13 +44,13 @@ This is useful for: * Verifying email content and formatting * Debugging email-related functionality without sending real emails -The backend is automatically configured to use Mailcatcher when running with Docker Compose locally (SMTP on port 1025). All captured emails can be viewed at . +The backend is automatically configured to use Mailcatcher when running with Docker Compose locally (SMTP on port 1025). All captured emails can be viewed at . ## Local Development The Docker Compose files are configured so that each of the services is available in a different port in `localhost`. -For the backend and frontend, they use the same port that would be used by their local development server, so, the backend is at `http://localhost:8000` and the frontend at `http://localhost:5173`. +For the backend and frontend, they use the same port that would be used by their local development server, so, the backend is at `http://localhost:8001` and the frontend at `http://localhost:5174`. This way, you could turn off a Docker Compose service and start its local development service, and everything would keep working, because it all uses the same ports. @@ -76,7 +76,7 @@ And then you can run the local development server for the backend: ```bash cd backend -fastapi dev app/main.py +fastapi dev app/main.py --port 8001 ``` ## Docker Compose in `localhost.tiangolo.com` @@ -188,19 +188,19 @@ The production or staging URLs would use these same paths, but with your own dom Development URLs, for local development. -Frontend: +Frontend: -Backend: +Backend: -Automatic Interactive Docs (Swagger UI): +Automatic Interactive Docs (Swagger UI): -Automatic Alternative Docs (ReDoc): +Automatic Alternative Docs (ReDoc): -Adminer: +Adminer: -Traefik UI: +Traefik UI: -MailCatcher: +MailCatcher: ### Development URLs with `localhost.tiangolo.com` Configured @@ -214,8 +214,8 @@ Automatic Interactive Docs (Swagger UI): -Adminer: +Adminer: -Traefik UI: +Traefik UI: -MailCatcher: +MailCatcher: diff --git a/frontend/README.md b/frontend/README.md index 7b50d58b3f..0fa59f76b8 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -13,7 +13,7 @@ bun install bun run dev ``` -* Then open your browser at http://localhost:5173/. +* Then open your browser at http://localhost:5174/. Notice that this live server is not running inside Docker, it's for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the frontend Docker image and start it, to test it in a production-like environment. But building the image at every change will not be as productive as running the local development server with live reload. diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 36f03d9919..e66d64b6df 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'http://localhost:5173', + baseURL: 'http://localhost:5174', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', @@ -85,7 +85,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { command: 'bun run dev', - url: 'http://localhost:5173', + url: 'http://localhost:5174', reuseExistingServer: !process.env.CI, }, }); diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 5c0c9c4a4e..aedacd8da2 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -57,6 +57,19 @@ export const Body_login_login_access_tokenSchema = { title: 'Body_login-login_access_token' } as const; +export const Body_users_update_user_avatarSchema = { + properties: { + file: { + type: 'string', + format: 'binary', + title: 'File' + } + }, + type: 'object', + required: ['file'], + title: 'Body_users-update_user_avatar' +} as const; + export const HTTPValidationErrorSchema = { properties: { detail: { @@ -318,6 +331,18 @@ export const UserCreateSchema = { ], title: 'Full Name' }, + avatar_url: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Avatar Url' + }, password: { type: 'string', maxLength: 128, @@ -360,6 +385,18 @@ export const UserPublicSchema = { ], title: 'Full Name' }, + avatar_url: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Avatar Url' + }, id: { type: 'string', format: 'uuid', @@ -452,6 +489,18 @@ export const UserUpdateSchema = { ], title: 'Full Name' }, + avatar_url: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Avatar Url' + }, password: { anyOf: [ { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f726..c23100c29b 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdateUserAvatarData, UsersUpdateUserAvatarResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; export class ItemsService { /** @@ -325,6 +325,26 @@ export class UsersService { }); } + /** + * Update User Avatar + * Upload user avatar. + * @param data The data for the request. + * @param data.formData + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static updateUserAvatar(data: UsersUpdateUserAvatarData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/users/me/avatar', + formData: data.formData, + mediaType: 'multipart/form-data', + errors: { + 422: 'Validation Error' + } + }); + } + /** * Update Password Me * Update own password. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index e62b56cad3..e1e2c8dcf0 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -9,6 +9,10 @@ export type Body_login_login_access_token = { client_secret?: (string | null); }; +export type Body_users_update_user_avatar = { + file: (Blob | File); +}; + export type HTTPValidationError = { detail?: Array; }; @@ -67,6 +71,7 @@ export type UserCreate = { is_active?: boolean; is_superuser?: boolean; full_name?: (string | null); + avatar_url?: (string | null); password: string; }; @@ -75,6 +80,7 @@ export type UserPublic = { is_active?: boolean; is_superuser?: boolean; full_name?: (string | null); + avatar_url?: (string | null); id: string; created_at?: (string | null); }; @@ -95,6 +101,7 @@ export type UserUpdate = { is_active?: boolean; is_superuser?: boolean; full_name?: (string | null); + avatar_url?: (string | null); password?: (string | null); }; @@ -196,6 +203,12 @@ export type UsersUpdateUserMeData = { export type UsersUpdateUserMeResponse = (UserPublic); +export type UsersUpdateUserAvatarData = { + formData: Body_users_update_user_avatar; +}; + +export type UsersUpdateUserAvatarResponse = (UserPublic); + export type UsersUpdatePasswordMeData = { requestBody: UpdatePassword; }; diff --git a/frontend/src/components/UserSettings/UserInformation.tsx b/frontend/src/components/UserSettings/UserInformation.tsx index 4bfaf600ff..05e4919a27 100644 --- a/frontend/src/components/UserSettings/UserInformation.tsx +++ b/frontend/src/components/UserSettings/UserInformation.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useMutation, useQueryClient } from "@tanstack/react-query" -import { useState } from "react" +import { type ChangeEvent, useRef, useState } from "react" import { useForm } from "react-hook-form" import { z } from "zod" @@ -34,6 +34,43 @@ const UserInformation = () => { const [editMode, setEditMode] = useState(false) const { user: currentUser } = useAuth() + const fileInputRef = useRef(null) + + const handleAvatarClick = () => { + fileInputRef.current?.click() + } + + const handleFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + const formData = new FormData() + formData.append("file", file) + + try { + const token = localStorage.getItem("access_token") + const response = await fetch( + `${import.meta.env.VITE_API_URL}/api/v1/users/me/avatar`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }, + ) + + if (!response.ok) { + throw new Error("Failed to upload avatar") + } + + showSuccessToast("Avatar updated successfully") + queryClient.invalidateQueries({ queryKey: ["currentUser"] }) + } catch (_error) { + showErrorToast("Error uploading avatar") + } + } + const form = useForm({ resolver: zodResolver(formSchema), mode: "onBlur", @@ -83,6 +120,37 @@ const UserInformation = () => { return (

User Information

+ +
+
+ {currentUser?.avatar_url ? ( + Avatar + ) : ( +
+ {currentUser?.full_name?.charAt(0) || + currentUser?.email?.charAt(0) || + "?"} +
+ )} +
+
+ + +
+
+
{ const selector = 'a[href*="/reset-password?token="]' let url = await page.getAttribute(selector, "href") - url = url!.replace("http://localhost/", "http://localhost:5173/") + url = url!.replace("http://localhost/", "http://localhost:5174/") // Set a weak new password await page.goto(url) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 874db9071f..a8fa5a0aba 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -19,4 +19,7 @@ export default defineConfig({ react(), tailwindcss(), ], + server: { + port: 5174, + }, })