mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Working on user, backend.
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
POSTGRES_SERVER=db
|
||||||
|
POSTGRES_USER=devuser
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
|
POSTGRES_DB=devdb
|
||||||
|
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_SERVER=ssl0.ovh.net
|
||||||
|
SENDER_MAIL=no-reply@soluce-technologies.com
|
||||||
|
SENDER_PW=
|
||||||
|
|
||||||
|
ALLOWED_HOSTS=["*"]
|
||||||
|
SECRET_KEY=changeme-secretkey
|
||||||
|
|
||||||
|
DEBUG=True
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
STAGE=True
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy import Column, DateTime, Integer
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from database import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Model(BaseModel):
|
||||||
|
__abstract__ = True
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel as BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class Schema(BaseSchema):
|
||||||
|
id: int | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import asc
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from . import models, schemas, services
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(db: Session, user_id: int):
|
||||||
|
return db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_users(db: Session, skip: int = 0, limit: int = 100):
|
||||||
|
return db.query(models.User).order_by(asc(models.User.id)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(db: Session, email: str):
|
||||||
|
return db.query(models.User).filter(models.User.email == email).first()
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(db: Session, user: schemas.UserCreate):
|
||||||
|
fake_hashed_password = services.get_password_hash(user.password)
|
||||||
|
db_user = models.User(email=user.email, hashed_password=fake_hashed_password)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from sqlalchemy import Column, Boolean, String, Float
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from apps.base.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
class User(Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
email = Column(String, unique=True, index=True)
|
||||||
|
hashed_password = Column(String)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from apps.base.schemas import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(Schema):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserBase):
|
||||||
|
id: int
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class Token(Schema):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(Schema):
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserInDB(User):
|
||||||
|
hashed_password: str
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from apps.users.crud import get_user_by_email
|
||||||
|
from apps.users.schemas import TokenData, User, UserInDB
|
||||||
|
from middleware.db_connection import get_db
|
||||||
|
from settings import config
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
fake_users_db = {
|
||||||
|
"johndoe": {
|
||||||
|
"username": "johndoe",
|
||||||
|
"full_name": "John Doe",
|
||||||
|
"email": "johndoe@example.com",
|
||||||
|
"hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
|
||||||
|
"disabled": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password, hashed_password):
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password):
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(db, username: str):
|
||||||
|
if username in db:
|
||||||
|
user_dict = db[username]
|
||||||
|
return UserInDB(**user_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(db, username: str, password: str):
|
||||||
|
user = get_user_by_email(db, username)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return False
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)], db: Session = Depends(get_db)):
|
||||||
|
print(token)
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
print(payload)
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
token_data = TokenData(username=username)
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
user = get_user_by_email(db, email=token_data.username)
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_active_user(
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)]
|
||||||
|
):
|
||||||
|
if not current_user.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
return current_user
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from settings import config
|
||||||
|
|
||||||
|
POSTGRES_SERVER = config.POSTGRES_SERVER
|
||||||
|
POSTGRES_USER = config.POSTGRES_USER
|
||||||
|
POSTGRES_PASSWORD = config.POSTGRES_PASSWORD
|
||||||
|
POSTGRES_DB = config.POSTGRES_DB
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_SERVER}/{POSTGRES_DB}"
|
||||||
|
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
BaseModel = declarative_base()
|
||||||
+22
-17
@@ -1,29 +1,34 @@
|
|||||||
import os
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
from starlette.staticfiles import StaticFiles
|
from starlette.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
from database import BaseModel, engine
|
||||||
|
from middleware.db_connection import DatabaseSessionMiddleware
|
||||||
|
|
||||||
from routes.main import api_router
|
from routes.main import api_router
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
app = FastAPI()
|
from settings import config
|
||||||
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
@asynccontextmanager
|
||||||
allow_origins=["*"],
|
async def lifespan(app: FastAPI):
|
||||||
allow_credentials=True,
|
print("Initialising database...")
|
||||||
allow_methods=["*"],
|
BaseModel.metadata.create_all(bind=engine)
|
||||||
allow_headers=["*"],
|
print("Database initialised!")
|
||||||
)
|
yield
|
||||||
|
print("App shutdown!")
|
||||||
|
|
||||||
|
|
||||||
|
middlewares = []
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan, middleware=middlewares)
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(DatabaseSessionMiddleware)
|
||||||
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=config.ALLOWED_HOSTS)
|
||||||
|
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
if os.getenv("ENV") == "production":
|
|
||||||
app.mount("/assets", StaticFiles(directory="static/assets"), name="assets")
|
|
||||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/{full_path:path}")
|
|
||||||
def read_react_app(full_path: str):
|
|
||||||
return FileResponse("static/index.html")
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import Request, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseSessionMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = Response("Internal server error", status_code=500)
|
||||||
|
try:
|
||||||
|
request.state.db = SessionLocal()
|
||||||
|
response = await call_next(request)
|
||||||
|
finally:
|
||||||
|
request.state.db.close()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def get_db(request: Request):
|
||||||
|
print(request)
|
||||||
|
return request.state.db
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
SQLAlchemy
|
||||||
|
psycopg2
|
||||||
|
|
||||||
|
requests
|
||||||
|
websockets
|
||||||
|
python-dotenv
|
||||||
|
|
||||||
# fastapi libraries
|
# fastapi libraries
|
||||||
fastapi[all]
|
fastapi[all]
|
||||||
fastapi-utilities
|
fastapi-utilities
|
||||||
@@ -7,3 +14,15 @@ pydantic-settings
|
|||||||
# deployment libraries
|
# deployment libraries
|
||||||
uvicorn==0.27.0
|
uvicorn==0.27.0
|
||||||
gunicorn
|
gunicorn
|
||||||
|
|
||||||
|
|
||||||
|
# cryptographic libraries
|
||||||
|
python-jose
|
||||||
|
PyJWT
|
||||||
|
passlib
|
||||||
|
paramiko
|
||||||
|
|
||||||
|
|
||||||
|
python-multipart==0.0.12
|
||||||
|
filetype
|
||||||
|
biplist
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["api"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
async def root():
|
|
||||||
return {"message": "Hello from backup agent!"}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Annotated
|
||||||
|
from apps.users.services import authenticate_user, fake_users_db, create_access_token, get_current_active_user
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
|
from apps.users import crud, models, schemas
|
||||||
|
from middleware.db_connection import get_db
|
||||||
|
|
||||||
|
from apps.users.schemas import User, UserCreate, Token
|
||||||
|
from settings import config
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", response_model=Token)
|
||||||
|
async def login_for_access_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Session = Depends(get_db)):
|
||||||
|
user = authenticate_user(db, form_data.username, form_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
access_token_expires = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(data={"sub": user.email}, expires_delta=access_token_expires)
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_model=list[User])
|
||||||
|
async def list_users(db: Session = Depends(get_db)):
|
||||||
|
db_users = crud.get_users(db)
|
||||||
|
return db_users
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=User)
|
||||||
|
async def read_users_me(current_user: Annotated[User, Depends(get_current_active_user)]):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=User)
|
||||||
|
def create_user(user: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
db_user = crud.get_user_by_email(db, email=user.email)
|
||||||
|
if db_user:
|
||||||
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
return crud.create_user(db=db, user=user)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/welcome", tags=["api"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"message": "Hello from portabase!"}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from routes.http import api
|
from routes.http import welcome, user
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter(prefix="/api", tags=["api"])
|
||||||
api_router.include_router(api.router)
|
api_router.include_router(welcome.router)
|
||||||
|
api_router.include_router(user.router)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from settings.development import DevSettings
|
||||||
|
from settings.production import ProdSettings
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings():
|
||||||
|
if os.getenv("ENVIRONMENT") == "development":
|
||||||
|
return DevSettings()
|
||||||
|
else:
|
||||||
|
return ProdSettings()
|
||||||
|
|
||||||
|
|
||||||
|
config = get_settings()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from dotenv import find_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
POSTGRES_SERVER: str
|
||||||
|
POSTGRES_USER: str
|
||||||
|
POSTGRES_PASSWORD: str
|
||||||
|
POSTGRES_DB: str
|
||||||
|
|
||||||
|
SMTP_PORT: int
|
||||||
|
SMTP_SERVER: str
|
||||||
|
SENDER_MAIL: str
|
||||||
|
SENDER_PW: str
|
||||||
|
HTTPS: bool = False
|
||||||
|
DEBUG: bool = False
|
||||||
|
ALLOWED_HOSTS: List[str]
|
||||||
|
SECRET_KEY: str # openssl rand -hex 32
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
STAGE: bool = False
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=find_dotenv(), extra='allow')
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from settings.base import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class DevSettings(Settings):
|
||||||
|
ALLOWED_HOSTS: List[str] = ["*"]
|
||||||
|
SECRET_KEY: str = "changeme-secretkey"
|
||||||
|
STAGE: bool = True
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from settings.base import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class ProdSettings(Settings):
|
||||||
|
CRON: bool = True
|
||||||
|
|
||||||
@@ -6,6 +6,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ../../backend:/src
|
- ../../backend:/src
|
||||||
command: uvicorn main:app --reload --host 0.0.0.0 --port 80
|
command: uvicorn main:app --reload --host 0.0.0.0 --port 80
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- 8888:80
|
- 8888:80
|
||||||
|
|
||||||
@@ -19,4 +23,27 @@ services:
|
|||||||
- ../../frontend:/app
|
- ../../frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=devdb
|
||||||
|
- POSTGRES_USER=devuser
|
||||||
|
- POSTGRES_PASSWORD=changeme
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb -h db" ]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- portabase-db:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
portabase-db:
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ WORKDIR /src
|
|||||||
ARG DEV=false
|
ARG DEV=false
|
||||||
RUN apt-get install openssh-client -y
|
RUN apt-get install openssh-client -y
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
|
RUN apt-get install -y postgresql postgresql-client binutils libproj-dev gdal-bin
|
||||||
|
|
||||||
RUN pip install -r /tmp/requirements.txt
|
RUN pip install -r /tmp/requirements.txt
|
||||||
RUN pip install -r /tmp/requirements.dev.txt
|
RUN pip install -r /tmp/requirements.dev.txt
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ ENV NODE_OPTIONS="--max-old-space-size=8192"
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
|
|
||||||
RUN yarn install -f
|
RUN npm install -f
|
||||||
|
|
||||||
CMD ["yarn", "run", "dev"]
|
CMD ["npm", "run", "dev"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+6928
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,7 @@
|
|||||||
"@typescript-eslint/parser": "^7.15.0",
|
"@typescript-eslint/parser": "^7.15.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^9.13.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.7",
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
|
|||||||
-3352
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user