sirius-cli 0.1.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {sirius_cli-0.1.0/sirius_cli.egg-info → sirius_cli-0.2.1}/PKG-INFO +5 -3
  2. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/README.md +4 -2
  3. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/pyproject.toml +1 -1
  4. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/cli.py +16 -4
  5. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/generator.py +27 -5
  6. sirius_cli-0.2.1/sirius_cli/templates/backend/auth.py.jinja2 +80 -0
  7. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/backend/main.py.jinja2 +20 -7
  8. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/backend/models.py.jinja2 +9 -0
  9. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/backend/requirements.txt.jinja2 +5 -0
  10. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/src/App.tsx.jinja2 +31 -0
  11. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/src/Dashboard.tsx.jinja2 +6 -6
  12. sirius_cli-0.2.1/sirius_cli/templates/frontend/src/Login.tsx.jinja2 +106 -0
  13. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/src/TableCrud.tsx.jinja2 +30 -6
  14. {sirius_cli-0.1.0 → sirius_cli-0.2.1/sirius_cli.egg-info}/PKG-INFO +5 -3
  15. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli.egg-info/SOURCES.txt +2 -0
  16. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/LICENSE +0 -0
  17. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/MANIFEST.in +0 -0
  18. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/setup.cfg +0 -0
  19. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/__init__.py +0 -0
  20. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/parser.py +0 -0
  21. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/backend/Dockerfile.jinja2 +0 -0
  22. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/backend/alembic/env.py.jinja2 +0 -0
  23. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/backend/alembic/script.py.mako.jinja2 +0 -0
  24. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/backend/database.py.jinja2 +0 -0
  25. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/backend/schemas.py.jinja2 +0 -0
  26. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/docker-compose.yml.jinja2 +0 -0
  27. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/.env.jinja2 +0 -0
  28. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/Dockerfile.jinja2 +0 -0
  29. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/index.html.jinja2 +0 -0
  30. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/package.json.jinja2 +0 -0
  31. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/postcss.config.js.jinja2 +0 -0
  32. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/src/index.css.jinja2 +0 -0
  33. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/src/main.tsx.jinja2 +0 -0
  34. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/tailwind.config.js.jinja2 +0 -0
  35. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/tsconfig.json.jinja2 +0 -0
  36. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli/templates/frontend/vite.config.ts.jinja2 +0 -0
  37. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli.egg-info/dependency_links.txt +0 -0
  38. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli.egg-info/entry_points.txt +0 -0
  39. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli.egg-info/requires.txt +0 -0
  40. {sirius_cli-0.1.0 → sirius_cli-0.2.1}/sirius_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sirius-cli
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: A rapid prototyping tool for generating containerized backend + frontend CRUD stacks from CSV/SQLite files.
5
5
  Requires-Python: >=3.8
6
6
  Description-Content-Type: text/markdown
@@ -36,11 +36,13 @@ A rapid prototyping CLI tool designed to take multiple CSV files, Excel spreadsh
36
36
 
37
37
  ## Installation
38
38
 
39
- Install the package locally in editable mode:
39
+ Install the package directly from PyPI:
40
40
  ```bash
41
- pip install -e .
41
+ pip install sirius-cli
42
42
  ```
43
43
 
44
+ *(For local development: clone the repository and run `pip install -e .`)*
45
+
44
46
  ---
45
47
 
46
48
  ## Usage: Creating a New Project (`sirius-init`)
@@ -20,11 +20,13 @@ A rapid prototyping CLI tool designed to take multiple CSV files, Excel spreadsh
20
20
 
21
21
  ## Installation
22
22
 
23
- Install the package locally in editable mode:
23
+ Install the package directly from PyPI:
24
24
  ```bash
25
- pip install -e .
25
+ pip install sirius-cli
26
26
  ```
27
27
 
28
+ *(For local development: clone the repository and run `pip install -e .`)*
29
+
28
30
  ---
29
31
 
30
32
  ## Usage: Creating a New Project (`sirius-init`)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sirius-cli"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "A rapid prototyping tool for generating containerized backend + frontend CRUD stacks from CSV/SQLite files."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -97,7 +97,10 @@ def init(
97
97
  api_url: Optional[str] = typer.Option(None, "--api-url", help="Override the frontend VITE_API_URL (default: http://localhost:<port>)"),
98
98
  no_seed: bool = typer.Option(False, "--no-seed", help="Skip seeding the database from CSV/Excel files"),
99
99
  pg: bool = typer.Option(False, "--pg", help="Use PostgreSQL instead of SQLite"),
100
- mysql: bool = typer.Option(False, "--mysql", help="Use MySQL instead of SQLite")
100
+ mysql: bool = typer.Option(False, "--mysql", help="Use MySQL instead of SQLite"),
101
+ auth: bool = typer.Option(False, "--auth", help="Generate JWT authentication logic"),
102
+ admin_user: str = typer.Option("admin", "--admin-user", help="Default admin username if --auth is used"),
103
+ admin_pass: str = typer.Option("admin", "--admin-pass", help="Default admin password if --auth is used")
101
104
  ):
102
105
  """Initializes a new FastAPI backend and React frontend stack from data files or configuration."""
103
106
  # Ensure one and only one source parameter is provided
@@ -150,7 +153,10 @@ def init(
150
153
  typer.echo(f"Scaffolding project in: {dest_dir} (Theme: {resolved_theme}, Port: {port}, DB: {db_type})")
151
154
 
152
155
  try:
153
- generate_project(dest_dir, schemas, theme=resolved_theme, port=port, api_url=resolved_api_url, db_type=db_type)
156
+ generate_project(
157
+ dest_dir, schemas, theme=resolved_theme, port=port, api_url=resolved_api_url,
158
+ db_type=db_type, auth=auth, admin_user=admin_user, admin_pass=admin_pass
159
+ )
154
160
  except Exception as e:
155
161
  typer.secho(f"Error generating files: {e}", fg=typer.colors.RED, err=True)
156
162
  raise typer.Exit(code=1)
@@ -259,7 +265,10 @@ def update(
259
265
  port: int = typer.Option(8000, "--port", "-p", help="Backend server port (used in docker-compose and .env)"),
260
266
  api_url: Optional[str] = typer.Option(None, "--api-url", help="Override the frontend VITE_API_URL (default: http://localhost:<port>)"),
261
267
  pg: bool = typer.Option(False, "--pg", help="Use PostgreSQL instead of SQLite"),
262
- mysql: bool = typer.Option(False, "--mysql", help="Use MySQL instead of SQLite")
268
+ mysql: bool = typer.Option(False, "--mysql", help="Use MySQL instead of SQLite"),
269
+ auth: bool = typer.Option(False, "--auth", help="Generate JWT authentication logic"),
270
+ admin_user: str = typer.Option("admin", "--admin-user", help="Default admin username if --auth is used"),
271
+ admin_pass: str = typer.Option("admin", "--admin-pass", help="Default admin password if --auth is used")
263
272
  ):
264
273
  """Updates an existing project with new columns/tables."""
265
274
  if not os.path.exists(project_path):
@@ -295,7 +304,10 @@ def update(
295
304
 
296
305
  typer.echo(f"Updating project in: {project_path}")
297
306
  try:
298
- generate_project(project_path, schemas, theme=theme, port=port, api_url=resolved_api_url, db_type=db_type)
307
+ generate_project(
308
+ project_path, schemas, theme=theme, port=port, api_url=resolved_api_url,
309
+ db_type=db_type, auth=auth, admin_user=admin_user, admin_pass=admin_pass
310
+ )
299
311
  except Exception as e:
300
312
  typer.secho(f"Error generating files: {e}", fg=typer.colors.RED, err=True)
301
313
  raise typer.Exit(code=1)
@@ -16,7 +16,11 @@ def render_template(env, template_name, dest_path, **kwargs):
16
16
  with open(dest_path, "w", encoding="utf-8") as f:
17
17
  f.write(content)
18
18
 
19
- def generate_project(project_path: str, schemas: dict, theme: str = "blue", port: int = 8000, api_url: str = "http://localhost:8000", db_type: str = "sqlite"):
19
+ def generate_project(
20
+ project_path: str, schemas: dict, theme: str = "blue",
21
+ port: int = 8000, api_url: str = "http://localhost:8000", db_type: str = "sqlite",
22
+ auth: bool = False, admin_user: str = "admin", admin_pass: str = "admin"
23
+ ):
20
24
  """Generates complete FastAPI and React frontend files structure based on inferred schemas and theme."""
21
25
  env = get_env()
22
26
 
@@ -29,7 +33,10 @@ def generate_project(project_path: str, schemas: dict, theme: str = "blue", port
29
33
  theme=theme,
30
34
  port=port,
31
35
  api_url=api_url,
32
- db_type=db_type
36
+ db_type=db_type,
37
+ auth=auth,
38
+ admin_user=admin_user,
39
+ admin_pass=admin_pass
33
40
  )
34
41
 
35
42
  # 2. Backend FastAPI application files
@@ -44,6 +51,9 @@ def generate_project(project_path: str, schemas: dict, theme: str = "blue", port
44
51
  "backend/Dockerfile.jinja2": "Dockerfile"
45
52
  }
46
53
 
54
+ if auth:
55
+ backend_templates["backend/auth.py.jinja2"] = "auth.py"
56
+
47
57
  for t_path, dest_name in backend_templates.items():
48
58
  render_template(
49
59
  env,
@@ -53,7 +63,10 @@ def generate_project(project_path: str, schemas: dict, theme: str = "blue", port
53
63
  theme=theme,
54
64
  port=port,
55
65
  api_url=api_url,
56
- db_type=db_type
66
+ db_type=db_type,
67
+ auth=auth,
68
+ admin_user=admin_user,
69
+ admin_pass=admin_pass
57
70
  )
58
71
 
59
72
  # Write init file to make backend a python package
@@ -78,6 +91,9 @@ def generate_project(project_path: str, schemas: dict, theme: str = "blue", port
78
91
  "frontend/src/Dashboard.tsx.jinja2": "src/Dashboard.tsx"
79
92
  }
80
93
 
94
+ if auth:
95
+ frontend_templates["frontend/src/Login.tsx.jinja2"] = "src/pages/Login.tsx"
96
+
81
97
  for t_path, dest_name in frontend_templates.items():
82
98
  render_template(
83
99
  env,
@@ -87,7 +103,10 @@ def generate_project(project_path: str, schemas: dict, theme: str = "blue", port
87
103
  theme=theme,
88
104
  port=port,
89
105
  api_url=api_url,
90
- db_type=db_type
106
+ db_type=db_type,
107
+ auth=auth,
108
+ admin_user=admin_user,
109
+ admin_pass=admin_pass
91
110
  )
92
111
 
93
112
  # 4. Generate dynamic CRUD view pages for each table
@@ -103,7 +122,10 @@ def generate_project(project_path: str, schemas: dict, theme: str = "blue", port
103
122
  theme=theme,
104
123
  port=port,
105
124
  api_url=api_url,
106
- db_type=db_type
125
+ db_type=db_type,
126
+ auth=auth,
127
+ admin_user=admin_user,
128
+ admin_pass=admin_pass
107
129
  )
108
130
 
109
131
  def render_alembic_files(backend_path: str, schemas: dict):
@@ -0,0 +1,80 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import Optional
3
+ from fastapi import APIRouter, Depends, HTTPException, status
4
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
5
+ from sqlalchemy.orm import Session
6
+ from passlib.context import CryptContext
7
+ from jose import JWTError, jwt
8
+ from pydantic import BaseModel
9
+ from .database import get_db
10
+ from .models import AppUser
11
+
12
+ SECRET_KEY = "super-secret-key-change-me-in-production"
13
+ ALGORITHM = "HS256"
14
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
15
+
16
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
17
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/token")
18
+
19
+ auth_router = APIRouter(prefix="/api/auth", tags=["auth"])
20
+
21
+ class Token(BaseModel):
22
+ access_token: str
23
+ token_type: str
24
+
25
+ def verify_password(plain_password, hashed_password):
26
+ return pwd_context.verify(plain_password, hashed_password)
27
+
28
+ def get_password_hash(password):
29
+ return pwd_context.hash(password)
30
+
31
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
32
+ to_encode = data.copy()
33
+ if expires_delta:
34
+ expire = datetime.utcnow() + expires_delta
35
+ else:
36
+ expire = datetime.utcnow() + timedelta(minutes=15)
37
+ to_encode.update({"exp": expire})
38
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
39
+ return encoded_jwt
40
+
41
+ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
42
+ credentials_exception = HTTPException(
43
+ status_code=status.HTTP_401_UNAUTHORIZED,
44
+ detail="Could not validate credentials",
45
+ headers={"WWW-Authenticate": "Bearer"},
46
+ )
47
+ try:
48
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
49
+ username: str = payload.get("sub")
50
+ if username is None:
51
+ raise credentials_exception
52
+ except JWTError:
53
+ raise credentials_exception
54
+ user = db.query(AppUser).filter(AppUser.username == username).first()
55
+ if user is None:
56
+ raise credentials_exception
57
+ return user
58
+
59
+ @auth_router.post("/token", response_model=Token)
60
+ def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
61
+ user = db.query(AppUser).filter(AppUser.username == form_data.username).first()
62
+ if not user or not verify_password(form_data.password, user.hashed_password):
63
+ raise HTTPException(
64
+ status_code=status.HTTP_401_UNAUTHORIZED,
65
+ detail="Incorrect username or password",
66
+ headers={"WWW-Authenticate": "Bearer"},
67
+ )
68
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
69
+ access_token = create_access_token(
70
+ data={"sub": user.username}, expires_delta=access_token_expires
71
+ )
72
+ return {"access_token": access_token, "token_type": "bearer"}
73
+
74
+ def seed_admin_user(db: Session):
75
+ admin_user = "{{ admin_user }}"
76
+ admin_pass = "{{ admin_pass }}"
77
+ if not db.query(AppUser).first():
78
+ db_user = AppUser(username=admin_user, hashed_password=get_password_hash(admin_pass))
79
+ db.add(db_user)
80
+ db.commit()
@@ -7,8 +7,11 @@ from sqlalchemy import or_, cast, String as SAString, desc as sa_desc, asc as sa
7
7
  from sqlalchemy.orm import Session
8
8
  from typing import List, Optional
9
9
 
10
- from .database import engine, get_db
10
+ from .database import engine, get_db, SessionLocal
11
11
  from . import models, schemas
12
+ {% if auth %}
13
+ from .auth import auth_router, get_current_user, seed_admin_user
14
+ {% endif %}
12
15
 
13
16
  # Automatically create tables if they do not exist
14
17
  models.Base.metadata.create_all(bind=engine)
@@ -23,6 +26,14 @@ app.add_middleware(
23
26
  allow_headers=["*"],
24
27
  )
25
28
 
29
+ {% if auth %}
30
+ # Seed initial admin user if table is empty
31
+ with SessionLocal() as db:
32
+ seed_admin_user(db)
33
+
34
+ app.include_router(auth_router)
35
+ {% endif %}
36
+
26
37
  @app.get("/")
27
38
  def read_root():
28
39
  return {"message": "Welcome to the generated Proto-CLI API!"}
@@ -55,7 +66,8 @@ def list_{{ table_name }}(
55
66
  search: Optional[str] = Query(None, description="Search across all text columns"),
56
67
  order_by: Optional[str] = Query(None, description="Column name to sort by"),
57
68
  dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
58
- db: Session = Depends(get_db)
69
+ db: Session = Depends(get_db){% if auth %},
70
+ current_user = Depends(get_current_user){% endif %}
59
71
  ):
60
72
  query = db.query(models.{{ model_name }})
61
73
 
@@ -79,7 +91,8 @@ def list_{{ table_name }}(
79
91
  @app.get("/api/{{ table_name }}/export")
80
92
  def export_{{ table_name }}(
81
93
  fmt: Optional[str] = Query("csv", description="Export format: csv or xlsx"),
82
- db: Session = Depends(get_db)
94
+ db: Session = Depends(get_db){% if auth %},
95
+ current_user = Depends(get_current_user){% endif %}
83
96
  ):
84
97
  records = db.query(models.{{ model_name }}).all()
85
98
  col_names = [c.name for c in models.{{ model_name }}.__table__.columns]
@@ -137,14 +150,14 @@ def export_{{ table_name }}(
137
150
  )
138
151
 
139
152
  @app.get("/api/{{ table_name }}/{id}", response_model=schemas.{{ schema_response }})
140
- def get_{{ table_name }}(id: int, db: Session = Depends(get_db)):
153
+ def get_{{ table_name }}(id: int, db: Session = Depends(get_db){% if auth %}, current_user = Depends(get_current_user){% endif %}):
141
154
  record = db.query(models.{{ model_name }}).filter(models.{{ model_name }}.id == id).first()
142
155
  if not record:
143
156
  raise HTTPException(status_code=404, detail="Record not found in {{ table_name }}")
144
157
  return record
145
158
 
146
159
  @app.post("/api/{{ table_name }}", response_model=schemas.{{ schema_response }}, status_code=status.HTTP_201_CREATED)
147
- def create_{{ table_name }}(payload: schemas.{{ schema_create }}, db: Session = Depends(get_db)):
160
+ def create_{{ table_name }}(payload: schemas.{{ schema_create }}, db: Session = Depends(get_db){% if auth %}, current_user = Depends(get_current_user){% endif %}):
148
161
  db_record = models.{{ model_name }}(**payload.model_dump())
149
162
  db.add(db_record)
150
163
  db.commit()
@@ -152,7 +165,7 @@ def create_{{ table_name }}(payload: schemas.{{ schema_create }}, db: Session =
152
165
  return db_record
153
166
 
154
167
  @app.put("/api/{{ table_name }}/{id}", response_model=schemas.{{ schema_response }})
155
- def update_{{ table_name }}(id: int, payload: schemas.{{ schema_update }}, db: Session = Depends(get_db)):
168
+ def update_{{ table_name }}(id: int, payload: schemas.{{ schema_update }}, db: Session = Depends(get_db){% if auth %}, current_user = Depends(get_current_user){% endif %}):
156
169
  db_record = db.query(models.{{ model_name }}).filter(models.{{ model_name }}.id == id).first()
157
170
  if not db_record:
158
171
  raise HTTPException(status_code=404, detail="Record not found in {{ table_name }}")
@@ -166,7 +179,7 @@ def update_{{ table_name }}(id: int, payload: schemas.{{ schema_update }}, db: S
166
179
  return db_record
167
180
 
168
181
  @app.delete("/api/{{ table_name }}/{id}", status_code=status.HTTP_204_NO_CONTENT)
169
- def delete_{{ table_name }}(id: int, db: Session = Depends(get_db)):
182
+ def delete_{{ table_name }}(id: int, db: Session = Depends(get_db){% if auth %}, current_user = Depends(get_current_user){% endif %}):
170
183
  db_record = db.query(models.{{ model_name }}).filter(models.{{ model_name }}.id == id).first()
171
184
  if not db_record:
172
185
  raise HTTPException(status_code=404, detail="Record not found in {{ table_name }}")
@@ -29,3 +29,12 @@ class {{ class_name }}(Base):
29
29
  {% endfor %}
30
30
 
31
31
  {% endfor %}
32
+
33
+ {% if auth %}
34
+ class AppUser(Base):
35
+ __tablename__ = "app_users"
36
+
37
+ id = Column(Integer, primary_key=True, index=True)
38
+ username = Column(String, unique=True, index=True, nullable=False)
39
+ hashed_password = Column(String, nullable=False)
40
+ {% endif %}
@@ -6,6 +6,11 @@ pydantic>=2.0.0
6
6
  pandas>=1.5.0
7
7
  openpyxl>=3.0.0
8
8
  email-validator>=2.0.0
9
+ {% if auth %}
10
+ passlib[bcrypt]>=1.7.4
11
+ python-jose[cryptography]>=3.3.0
12
+ python-multipart>=0.0.6
13
+ {% endif %}
9
14
  {% if db_type == 'pg' %}
10
15
  psycopg2-binary>=2.9.0
11
16
  {% elif db_type == 'mysql' %}
@@ -2,6 +2,11 @@ import React from 'react'
2
2
  import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
3
3
  import { Database, Table, LayoutDashboard, ChevronRight } from 'lucide-react'
4
4
  import Dashboard from './Dashboard'
5
+ {% if auth %}
6
+ import { useState } from 'react'
7
+ import Login from './pages/Login'
8
+ import { LogOut } from 'lucide-react'
9
+ {% endif %}
5
10
  {% for table_name, columns in schemas.items() %}
6
11
  import {{ table_name.replace('_', ' ').title().replace(' ', '') }}Crud from './pages/{{ table_name.replace('_', ' ').title().replace(' ', '') }}Crud'
7
12
  {% endfor %}
@@ -26,6 +31,23 @@ const SidebarLink = ({ to, children, icon: Icon }: { to: string; children: React
26
31
  }
27
32
 
28
33
  function AppContent() {
34
+ {% if auth %}
35
+ const [token, setToken] = useState<string | null>(localStorage.getItem('token'))
36
+
37
+ const handleLogin = (newToken: string) => {
38
+ localStorage.setItem('token', newToken)
39
+ setToken(newToken)
40
+ }
41
+
42
+ const handleLogout = () => {
43
+ localStorage.removeItem('token')
44
+ setToken(null)
45
+ }
46
+
47
+ if (!token) {
48
+ return <Login onLogin={handleLogin} />
49
+ }
50
+ {% endif %}
29
51
  return (
30
52
  <div className="flex min-h-screen bg-slate-950 text-slate-100 font-sans">
31
53
  {/* Sidebar */}
@@ -53,6 +75,15 @@ function AppContent() {
53
75
  </nav>
54
76
 
55
77
  <div className="mt-auto border-t border-slate-900 pt-4 px-2">
78
+ {% if auth %}
79
+ <button
80
+ onClick={handleLogout}
81
+ className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-xl transition-all duration-200 mb-4"
82
+ >
83
+ <LogOut className="w-4 h-4" />
84
+ Logout
85
+ </button>
86
+ {% endif %}
56
87
  <p className="text-xs text-slate-500">FastAPI + React SPA</p>
57
88
  <p className="text-[10px] text-slate-600 mt-1">Status: <span className="text-emerald-500 font-semibold">Online</span></p>
58
89
  </div>
@@ -19,7 +19,7 @@ export default function Dashboard() {
19
19
  const fetchMetadata = async () => {
20
20
  try {
21
21
  setLoading(true)
22
- const res = await fetch(`${API_BASE}/api/metadata`)
22
+ const res = await fetch(`${API_BASE}/api/metadata`{% if auth %}, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }{% endif %})
23
23
  if (!res.ok) throw new Error('Failed to fetch system metadata')
24
24
  const data = await res.json()
25
25
  setMetadata(data)
@@ -100,7 +100,7 @@ export default function Dashboard() {
100
100
  </h3>
101
101
  <div className="h-72 w-full">
102
102
  <ResponsiveContainer width="100%" height="100%">
103
- <BarChart data={metadata} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
103
+ <BarChart data={metadata} margin={ { top: 10, right: 10, left: -20, bottom: 0 } }>
104
104
  <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
105
105
  <XAxis
106
106
  dataKey="name"
@@ -118,10 +118,10 @@ export default function Dashboard() {
118
118
  tickFormatter={(val) => val >= 1000 ? `${(val / 1000).toFixed(1)}k` : val}
119
119
  />
120
120
  <Tooltip
121
- cursor={{ fill: '#0f172a' }}
122
- contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', borderRadius: '0.75rem' }}
123
- itemStyle={{ color: '#38bdf8' }}
124
- labelStyle={{ color: '#94a3b8', textTransform: 'capitalize', marginBottom: '4px' }}
121
+ cursor={ { fill: '#0f172a' } }
122
+ contentStyle={ { backgroundColor: '#0f172a', borderColor: '#1e293b', borderRadius: '0.75rem' } }
123
+ itemStyle={ { color: '#38bdf8' } }
124
+ labelStyle={ { color: '#94a3b8', textTransform: 'capitalize', marginBottom: '4px' } }
125
125
  formatter={(value: number) => [value, 'Records']}
126
126
  labelFormatter={(label: string) => label.replace('_', ' ')}
127
127
  />
@@ -0,0 +1,106 @@
1
+ import React, { useState } from 'react';
2
+ import { Database, Lock, User, AlertCircle } from 'lucide-react';
3
+
4
+ export default function Login({ onLogin }: { onLogin: (token: str) => void }) {
5
+ const [username, setUsername] = useState('');
6
+ const [password, setPassword] = useState('');
7
+ const [error, setError] = useState('');
8
+ const [loading, setLoading] = useState(false);
9
+
10
+ const handleSubmit = async (e: React.FormEvent) => {
11
+ e.preventDefault();
12
+ setError('');
13
+ setLoading(true);
14
+
15
+ try {
16
+ const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/token`, {
17
+ method: 'POST',
18
+ headers: {
19
+ 'Content-Type': 'application/x-www-form-urlencoded',
20
+ },
21
+ body: new URLSearchParams({
22
+ username,
23
+ password,
24
+ }),
25
+ });
26
+
27
+ if (!response.ok) {
28
+ throw new Error('Invalid credentials');
29
+ }
30
+
31
+ const data = await response.json();
32
+ onLogin(data.access_token);
33
+ } catch (err: any) {
34
+ setError(err.message || 'An error occurred during login');
35
+ } finally {
36
+ setLoading(false);
37
+ }
38
+ };
39
+
40
+ return (
41
+ <div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
42
+ <div className="w-full max-w-md">
43
+ <div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-3xl p-8 shadow-2xl">
44
+ <div className="flex flex-col items-center mb-8">
45
+ <div className="p-3 bg-brand-500/10 text-brand-400 rounded-2xl border border-brand-500/20 mb-4">
46
+ <Database className="w-8 h-8" />
47
+ </div>
48
+ <h1 className="text-2xl font-outfit font-bold text-white mb-2">Welcome Back</h1>
49
+ <p className="text-slate-400 text-sm text-center">Sign in to access your administrative dashboard.</p>
50
+ </div>
51
+
52
+ {error && (
53
+ <div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3 text-red-400 text-sm">
54
+ <AlertCircle className="w-5 h-5 flex-shrink-0" />
55
+ <p>{error}</p>
56
+ </div>
57
+ )}
58
+
59
+ <form onSubmit={handleSubmit} className="space-y-5">
60
+ <div>
61
+ <label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 ml-1">Username</label>
62
+ <div className="relative">
63
+ <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
64
+ <User className="w-5 h-5 text-slate-500" />
65
+ </div>
66
+ <input
67
+ type="text"
68
+ value={username}
69
+ onChange={(e) => setUsername(e.target.value)}
70
+ className="w-full pl-11 pr-4 py-3 bg-slate-950/50 border border-slate-800 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-all"
71
+ placeholder="Enter your username"
72
+ required
73
+ />
74
+ </div>
75
+ </div>
76
+
77
+ <div>
78
+ <label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 ml-1">Password</label>
79
+ <div className="relative">
80
+ <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
81
+ <Lock className="w-5 h-5 text-slate-500" />
82
+ </div>
83
+ <input
84
+ type="password"
85
+ value={password}
86
+ onChange={(e) => setPassword(e.target.value)}
87
+ className="w-full pl-11 pr-4 py-3 bg-slate-950/50 border border-slate-800 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-all"
88
+ placeholder="••••••••"
89
+ required
90
+ />
91
+ </div>
92
+ </div>
93
+
94
+ <button
95
+ type="submit"
96
+ disabled={loading}
97
+ className="w-full py-3 px-4 bg-gradient-to-r from-brand-600 to-brand-500 hover:from-brand-500 hover:to-brand-400 text-white rounded-xl font-medium shadow-lg shadow-brand-500/25 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
98
+ >
99
+ {loading ? 'Authenticating...' : 'Sign In'}
100
+ </button>
101
+ </form>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState, useCallback } from 'react'
1
+ import React, { useEffect, useState, useCallback } from 'react'
2
2
  import { useNavigate } from 'react-router-dom'
3
3
  import { Plus, Edit2, Trash2, X, ChevronLeft, ChevronRight, Loader2, Search, Download, ArrowUpDown, ArrowUp, ArrowDown, FileSpreadsheet } from 'lucide-react'
4
4
 
@@ -78,7 +78,7 @@ export default function {{ table_name.replace('_', ' ').title().replace(' ', '')
78
78
  params.set('order_by', sortCol)
79
79
  params.set('dir', sortDir)
80
80
  }
81
- const res = await fetch(`${API_BASE}/api/{{ table_name }}?${params}`)
81
+ const res = await fetch(`${API_BASE}/api/{{ table_name }}?${params}`{% if auth %}, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }{% endif %})
82
82
  if (!res.ok) throw new Error('Failed to load data from server')
83
83
  const data = await res.json()
84
84
  setRecords(data)
@@ -99,7 +99,7 @@ export default function {{ table_name.replace('_', ' ').title().replace(' ', '')
99
99
  {% for col in columns %}
100
100
  {% if col.foreign_key %}
101
101
  {% set target_table = col.foreign_key.split('.')[0] %}
102
- fetch(`${API_BASE}/api/{{ target_table }}?limit=500`)
102
+ fetch(`${API_BASE}/api/{{ target_table }}?limit=500`{% if auth %}, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }{% endif %})
103
103
  .then(res => {
104
104
  if (!res.ok) throw new Error(`Server responded ${res.status}`)
105
105
  return res.json()
@@ -182,7 +182,10 @@ export default function {{ table_name.replace('_', ' ').title().replace(' ', '')
182
182
 
183
183
  const res = await fetch(url, {
184
184
  method,
185
- headers: { 'Content-Type': 'application/json' },
185
+ headers: {
186
+ 'Content-Type': 'application/json'{% if auth %},
187
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`{% endif %}
188
+ },
186
189
  body: JSON.stringify(payload)
187
190
  })
188
191
 
@@ -200,7 +203,8 @@ export default function {{ table_name.replace('_', ' ').title().replace(' ', '')
200
203
  const handleDelete = async (id: number) => {
201
204
  try {
202
205
  const res = await fetch(`${API_BASE}/api/{{ table_name }}/${id}`, {
203
- method: 'DELETE'
206
+ method: 'DELETE'{% if auth %},
207
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }{% endif %}
204
208
  })
205
209
  if (!res.ok) throw new Error('Failed to delete record')
206
210
  setDeleteConfirmId(null)
@@ -210,8 +214,28 @@ export default function {{ table_name.replace('_', ' ').title().replace(' ', '')
210
214
  }
211
215
  }
212
216
 
213
- const handleExport = (fmt: 'csv' | 'xlsx') => {
217
+ const handleExport = async (fmt: 'csv' | 'xlsx') => {
218
+ {% if auth %}
219
+ try {
220
+ const res = await fetch(`${API_BASE}/api/{{ table_name }}/export?fmt=${fmt}`, {
221
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
222
+ })
223
+ if (!res.ok) throw new Error('Export failed')
224
+ const blob = await res.blob()
225
+ const url = window.URL.createObjectURL(blob)
226
+ const a = document.createElement('a')
227
+ a.href = url
228
+ a.download = `{{ table_name }}.${fmt}`
229
+ document.body.appendChild(a)
230
+ a.click()
231
+ a.remove()
232
+ window.URL.revokeObjectURL(url)
233
+ } catch (err: any) {
234
+ alert(err.message || 'Export failed')
235
+ }
236
+ {% else %}
214
237
  window.open(`${API_BASE}/api/{{ table_name }}/export?fmt=${fmt}`, '_blank')
238
+ {% endif %}
215
239
  }
216
240
 
217
241
  const handleSort = (colName: string) => {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sirius-cli
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: A rapid prototyping tool for generating containerized backend + frontend CRUD stacks from CSV/SQLite files.
5
5
  Requires-Python: >=3.8
6
6
  Description-Content-Type: text/markdown
@@ -36,11 +36,13 @@ A rapid prototyping CLI tool designed to take multiple CSV files, Excel spreadsh
36
36
 
37
37
  ## Installation
38
38
 
39
- Install the package locally in editable mode:
39
+ Install the package directly from PyPI:
40
40
  ```bash
41
- pip install -e .
41
+ pip install sirius-cli
42
42
  ```
43
43
 
44
+ *(For local development: clone the repository and run `pip install -e .`)*
45
+
44
46
  ---
45
47
 
46
48
  ## Usage: Creating a New Project (`sirius-init`)
@@ -14,6 +14,7 @@ sirius_cli.egg-info/requires.txt
14
14
  sirius_cli.egg-info/top_level.txt
15
15
  sirius_cli/templates/docker-compose.yml.jinja2
16
16
  sirius_cli/templates/backend/Dockerfile.jinja2
17
+ sirius_cli/templates/backend/auth.py.jinja2
17
18
  sirius_cli/templates/backend/database.py.jinja2
18
19
  sirius_cli/templates/backend/main.py.jinja2
19
20
  sirius_cli/templates/backend/models.py.jinja2
@@ -31,6 +32,7 @@ sirius_cli/templates/frontend/tsconfig.json.jinja2
31
32
  sirius_cli/templates/frontend/vite.config.ts.jinja2
32
33
  sirius_cli/templates/frontend/src/App.tsx.jinja2
33
34
  sirius_cli/templates/frontend/src/Dashboard.tsx.jinja2
35
+ sirius_cli/templates/frontend/src/Login.tsx.jinja2
34
36
  sirius_cli/templates/frontend/src/TableCrud.tsx.jinja2
35
37
  sirius_cli/templates/frontend/src/index.css.jinja2
36
38
  sirius_cli/templates/frontend/src/main.tsx.jinja2
File without changes
File without changes
File without changes