diff --git a/.woodpecker.yaml b/.woodpecker.yaml index b29e387..0555838 100644 --- a/.woodpecker.yaml +++ b/.woodpecker.yaml @@ -100,6 +100,9 @@ steps: trigger-gitops-via-push: + when: + branch: [ master, develop ] + event: [ push ] name: Trigger apps-gitops via Git push image: alpine/git environment: diff --git a/backend/.env b/backend/.env index 901386b..a5b0fa9 100644 --- a/backend/.env +++ b/backend/.env @@ -1,4 +1,14 @@ MINIO_ACCESS_KEY=TDJvsBmbkpUXpCw5M7LA MINIO_SECRET_KEY=n9scR7W0MZy6FF0bznV98fSgXpdebIQjqZvEr1Yu MINIO_ENDPOINT=s3.dvirlabs.com -MINIO_BUCKET=navix-icons \ No newline at end of file +MINIO_BUCKET=navix-icons + +# PostgreSQL Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=navix +DB_USER=navix_user +DB_PASSWORD=Aa123456 + +# JWT Authentication +JWT_SECRET_KEY=9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..f376610 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +# MinIO Configuration +MINIO_ENDPOINT=s3.dvirlabs.com +MINIO_ACCESS_KEY=your-access-key +MINIO_SECRET_KEY=your-secret-key +MINIO_BUCKET=navix-icons + +# PostgreSQL Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=navix +DB_USER=navix_user +DB_PASSWORD=navix_secure_password_change_me + +# JWT Authentication +JWT_SECRET_KEY=your-super-secret-jwt-key-change-this-in-production-min-32-chars diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..3e83252 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,135 @@ +# Navix Backend - PostgreSQL Setup Guide + +## Setup Instructions + +### 1. Install PostgreSQL + +**Windows:** +```bash +# Download from https://www.postgresql.org/download/windows/ +# Or use chocolatey: +choco install postgresql +``` + +**Linux/WSL:** +```bash +sudo apt update +sudo apt install postgresql postgresql-contrib +``` + +**macOS:** +```bash +brew install postgresql +``` + +### 2. Start PostgreSQL Service + +**Windows:** +```bash +# PostgreSQL should start automatically as a service +# Or start manually: +pg_ctl -D "C:\Program Files\PostgreSQL\{version}\data" start +``` + +**Linux/WSL:** +```bash +sudo service postgresql start +``` + +**macOS:** +```bash +brew services start postgresql +``` + +### 3. Create Database and Schema + +```bash +# Run the schema file as postgres superuser +psql -U postgres -f backend/schema.sql + +# This will: +# 1. Create a dedicated 'navix_user' database user +# 2. Create the 'navix' database owned by navix_user +# 3. Create all tables and schemas +# 4. Grant appropriate privileges + +# IMPORTANT: Change the password in the SQL file before running! +# Edit backend/schema.sql and change 'navix_secure_password_change_me' to a strong password +``` + +### 4. Configure Environment Variables + +Copy `.env.example` to `.env` and update with your settings: + +```bash +cp .env.example .env +``` + +Edit `.env` with your actual database credentials and JWT secret key. + +### 5. Install Python Dependencies + +```bash +cd backend +pip install -r requirements.txt +``` + +### 6. Run the Backend + +```bash +python main.py +``` + +## API Endpoints + +### Authentication +- `POST /api/auth/register` - Register new user +- `POST /api/auth/login` - Login and get JWT token +- `GET /api/auth/me` - Get current user info (requires auth) + +### Sections +- `GET /api/sections` - Get all sections with apps (requires auth) +- `POST /api/sections` - Create new section (requires auth) + +### Apps +- `POST /api/apps` - Create new app (requires auth) +- `PUT /api/apps/{app_id}` - Update app (requires auth) +- `DELETE /api/apps/{app_id}` - Delete app (requires auth) + +### Legacy (YAML-based) +- `GET /api/apps` - Get apps from YAML file +- `POST /api/add_app` - Add app to YAML file +- `POST /api/edit_app` - Edit app in YAML file +- `POST /api/delete_app` - Delete app from YAML file + +## Authentication Flow + +1. **Register**: `POST /api/auth/register` with `{username, email, password}` +2. **Login**: `POST /api/auth/login` with `{username, password}` → Returns JWT token +3. **Use Token**: Include in Authorization header: `Bearer {token}` + +## Testing with cURL + +```bash +# Register +curl -X POST http://localhost:8000/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username":"testuser","email":"test@example.com","password":"password123"}' + +# Login +curl -X POST http://localhost:8000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"testuser","password":"password123"}' + +# Get sections (use token from login) +curl -X GET http://localhost:8000/api/sections \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + +## Database Schema + +- **users**: User accounts with authentication +- **sections**: User-specific app sections +- **apps**: Applications within sections + +Each user has their own personal view with their own sections and apps. diff --git a/backend/__pycache__/auth.cpython-313.pyc b/backend/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..1633cab Binary files /dev/null and b/backend/__pycache__/auth.cpython-313.pyc differ diff --git a/backend/__pycache__/database.cpython-313.pyc b/backend/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..40cf970 Binary files /dev/null and b/backend/__pycache__/database.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc index 1be8463..ef15d26 100644 Binary files a/backend/__pycache__/main.cpython-313.pyc and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/__pycache__/models.cpython-313.pyc b/backend/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..e45d635 Binary files /dev/null and b/backend/__pycache__/models.cpython-313.pyc differ diff --git a/backend/apps.yaml b/backend/apps.yaml index 26307a1..cf68377 100644 --- a/backend/apps.yaml +++ b/backend/apps.yaml @@ -45,3 +45,15 @@ sections: name: Minio url: https://minio.dvirlabs.com name: Infra +- apps: + - description: dvir + icon: '' + name: test-app + url: https://git.dvirlabs.com + name: test +- apps: + - description: sdf + icon: '' + name: dsfds + url: https://git.dvirlabs.com + name: sdf diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..8431780 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,81 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +import bcrypt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import os + +# JWT Configuration +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-this-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days + +# HTTP Bearer token scheme +security = HTTPBearer() + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash""" + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + + +def get_password_hash(password: str) -> str: + """Hash a password using bcrypt""" + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode('utf-8'), salt) + return hashed.decode('utf-8') + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> dict: + """Decode and verify a JWT token""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict: + """Dependency to get the current authenticated user from JWT token""" + try: + token = credentials.credentials + payload = decode_access_token(token) + + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials - no user_id", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Convert to int if it's a string + if isinstance(user_id, str): + user_id = int(user_id) + + return {"user_id": user_id, "username": payload.get("username")} + except Exception as e: + print(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Could not validate credentials: {str(e)}", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..fa7301d --- /dev/null +++ b/backend/database.py @@ -0,0 +1,56 @@ +import psycopg2 +from psycopg2.extras import RealDictCursor +import os +from contextlib import contextmanager + +# Database configuration from environment variables +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "navix") +DB_USER = os.getenv("DB_USER", "postgres") +DB_PASSWORD = os.getenv("DB_PASSWORD", "postgres") + + +def get_connection(): + """Create a new database connection""" + return psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + cursor_factory=RealDictCursor + ) + + +@contextmanager +def get_db_cursor(commit=False): + """Context manager for database operations""" + conn = get_connection() + cursor = conn.cursor() + try: + yield cursor + if commit: + conn.commit() + except Exception as e: + conn.rollback() + raise e + finally: + cursor.close() + conn.close() + + +def init_db(): + """Test database connection""" + try: + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT version();") + version = cursor.fetchone() + print(f"Connected to PostgreSQL: {version}") + cursor.close() + conn.close() + return True + except Exception as e: + print(f"Database connection failed: {e}") + return False diff --git a/backend/main.py b/backend/main.py index 6345918..9c1445a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, APIRouter +from fastapi import FastAPI, APIRouter, Depends, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse import uvicorn @@ -6,10 +6,32 @@ import os from dotenv import load_dotenv import yaml from pathlib import Path -from pydantic import BaseModel from minio import Minio +from datetime import timedelta -app = FastAPI() +from database import get_db_cursor, init_db +from auth import ( + get_password_hash, + verify_password, + create_access_token, + get_current_user, + ACCESS_TOKEN_EXPIRE_MINUTES +) +from models import ( + UserRegister, + UserLogin, + Token, + UserResponse, + SectionCreate, + SectionResponse, + AppCreate, + AppUpdate, + AppResponse, + AppEntry, + AppData +) + +app = FastAPI(title="Navix API", version="2.0.0") router = APIRouter() app.add_middleware( @@ -36,35 +58,292 @@ minio_client = Minio( BUCKET = MINIO_BUCKET or "navix-icons" APPS_FILE = Path(__file__).parent / "apps.yaml" +# Initialize database connection +@app.on_event("startup") +async def startup_event(): + if init_db(): + print("✅ Database connected successfully") + else: + print("⚠️ Database connection failed") + + +# ============================================================================ +# Authentication Endpoints +# ============================================================================ + +@router.post("/auth/register", response_model=Token, status_code=status.HTTP_201_CREATED) +def register(user_data: UserRegister): + """Register a new user""" + with get_db_cursor(commit=True) as cursor: + # Check if username exists + cursor.execute("SELECT id FROM users WHERE username = %s", (user_data.username,)) + if cursor.fetchone(): + raise HTTPException(status_code=400, detail="Username already exists") + + # Check if email exists + cursor.execute("SELECT id FROM users WHERE email = %s", (user_data.email,)) + if cursor.fetchone(): + raise HTTPException(status_code=400, detail="Email already exists") + + # Create user + hashed_password = get_password_hash(user_data.password) + cursor.execute( + """ + INSERT INTO users (username, email, password_hash) + VALUES (%s, %s, %s) + RETURNING id, username, email, created_at + """, + (user_data.username, user_data.email, hashed_password) + ) + user = cursor.fetchone() + + # Create access token + access_token = create_access_token( + data={"sub": user["id"], "username": user["username"]}, + expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + ) + + return Token( + access_token=access_token, + user=UserResponse(**user) + ) + + +@router.post("/auth/login", response_model=Token) +def login(credentials: UserLogin): + """Login user and return JWT token""" + with get_db_cursor() as cursor: + cursor.execute( + "SELECT id, username, email, password_hash, created_at FROM users WHERE username = %s", + (credentials.username,) + ) + user = cursor.fetchone() + + if not user or not verify_password(credentials.password, user["password_hash"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password" + ) + + # Create access token + access_token = create_access_token( + data={"sub": user["id"], "username": user["username"]}, + expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + ) + + return Token( + access_token=access_token, + user=UserResponse( + id=user["id"], + username=user["username"], + email=user["email"], + created_at=user["created_at"] + ) + ) + + +@router.get("/auth/me", response_model=UserResponse) +def get_me(current_user: dict = Depends(get_current_user)): + """Get current user information""" + with get_db_cursor() as cursor: + cursor.execute( + "SELECT id, username, email, created_at FROM users WHERE id = %s", + (current_user["user_id"],) + ) + user = cursor.fetchone() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return UserResponse(**user) + + +# ============================================================================ +# Section Endpoints +# ============================================================================ + +@router.get("/sections", response_model=list[SectionResponse]) +def get_sections(current_user: dict = Depends(get_current_user)): + """Get all sections with apps for the current user""" + with get_db_cursor() as cursor: + # Get sections for user + cursor.execute( + """ + SELECT id, name, display_order + FROM sections + WHERE user_id = %s + ORDER BY display_order, name + """, + (current_user["user_id"],) + ) + sections = cursor.fetchall() + + result = [] + for section in sections: + # Get apps for each section + cursor.execute( + """ + SELECT id, section_id, name, url, icon, description, display_order + FROM apps + WHERE section_id = %s + ORDER BY display_order, name + """, + (section["id"],) + ) + apps = cursor.fetchall() + + result.append({ + "id": section["id"], + "name": section["name"], + "display_order": section["display_order"], + "apps": [dict(app) for app in apps] + }) + + return result + + +@router.post("/sections", response_model=SectionResponse, status_code=status.HTTP_201_CREATED) +def create_section(section: SectionCreate, current_user: dict = Depends(get_current_user)): + """Create a new section for the current user""" + with get_db_cursor(commit=True) as cursor: + cursor.execute( + """ + INSERT INTO sections (user_id, name, display_order) + VALUES (%s, %s, COALESCE((SELECT MAX(display_order) + 1 FROM sections WHERE user_id = %s), 0)) + RETURNING id, name, display_order + """, + (current_user["user_id"], section.name, current_user["user_id"]) + ) + new_section = cursor.fetchone() + return SectionResponse(**new_section, apps=[]) + + +# ============================================================================ +# App Endpoints +# ============================================================================ + +@router.post("/apps", response_model=AppResponse, status_code=status.HTTP_201_CREATED) +def create_app(app: AppCreate, current_user: dict = Depends(get_current_user)): + """Create a new app in a section""" + with get_db_cursor(commit=True) as cursor: + # Verify section belongs to user + cursor.execute( + "SELECT id FROM sections WHERE id = %s AND user_id = %s", + (app.section_id, current_user["user_id"]) + ) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="Section not found") + + # Create app + cursor.execute( + """ + INSERT INTO apps (section_id, name, url, icon, description, display_order) + VALUES (%s, %s, %s, %s, %s, COALESCE((SELECT MAX(display_order) + 1 FROM apps WHERE section_id = %s), 0)) + RETURNING id, section_id, name, url, icon, description, display_order + """, + (app.section_id, app.name, app.url, app.icon, app.description, app.section_id) + ) + new_app = cursor.fetchone() + return AppResponse(**new_app) + + +@router.put("/apps/{app_id}", response_model=AppResponse) +def update_app(app_id: int, app_update: AppUpdate, current_user: dict = Depends(get_current_user)): + """Update an existing app""" + with get_db_cursor(commit=True) as cursor: + # Verify app belongs to user + cursor.execute( + """ + SELECT a.id FROM apps a + JOIN sections s ON a.section_id = s.id + WHERE a.id = %s AND s.user_id = %s + """, + (app_id, current_user["user_id"]) + ) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="App not found") + + # Build update query dynamically + update_fields = [] + values = [] + + if app_update.name is not None: + update_fields.append("name = %s") + values.append(app_update.name) + if app_update.url is not None: + update_fields.append("url = %s") + values.append(app_update.url) + if app_update.icon is not None: + update_fields.append("icon = %s") + values.append(app_update.icon) + if app_update.description is not None: + update_fields.append("description = %s") + values.append(app_update.description) + if app_update.section_id is not None: + # Verify new section belongs to user + cursor.execute( + "SELECT id FROM sections WHERE id = %s AND user_id = %s", + (app_update.section_id, current_user["user_id"]) + ) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="Target section not found") + update_fields.append("section_id = %s") + values.append(app_update.section_id) + + if not update_fields: + raise HTTPException(status_code=400, detail="No fields to update") + + values.append(app_id) + query = f"UPDATE apps SET {', '.join(update_fields)} WHERE id = %s RETURNING id, section_id, name, url, icon, description, display_order" + + cursor.execute(query, values) + updated_app = cursor.fetchone() + return AppResponse(**updated_app) + + +@router.delete("/apps/{app_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_app_by_id(app_id: int, current_user: dict = Depends(get_current_user)): + """Delete an app""" + with get_db_cursor(commit=True) as cursor: + # Verify app belongs to user + cursor.execute( + """ + DELETE FROM apps + WHERE id = %s AND section_id IN ( + SELECT id FROM sections WHERE user_id = %s + ) + RETURNING id + """, + (app_id, current_user["user_id"]) + ) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="App not found") + + +# ============================================================================ +# Legacy YAML Endpoints (for backward compatibility) +# ============================================================================ + + +# ============================================================================ +# Legacy YAML Endpoints (for backward compatibility) +# ============================================================================ @router.get("/") def root(): - return {"message": "Welcome to the FastAPI application!"} + return {"message": "Welcome to Navix API v2.0!"} @router.get("/apps") def get_apps(): + """Legacy endpoint - returns apps from YAML file""" if not APPS_FILE.exists(): return {"error": "apps.yaml not found"} with open(APPS_FILE, "r") as f: return yaml.safe_load(f) -class AppData(BaseModel): - name: str - icon: str - description: str - url: str - - -class AppEntry(BaseModel): - section: str - app: AppData - original_name: str | None = None - - @router.post("/add_app") def add_app(entry: AppEntry): + """Legacy endpoint - adds app to YAML file""" if not APPS_FILE.exists(): current = {"sections": []} else: @@ -89,6 +368,7 @@ def add_app(entry: AppEntry): @router.post("/edit_app") def edit_app(entry: AppEntry): + """Legacy endpoint - edits app in YAML file""" if not APPS_FILE.exists(): return {"error": "apps.yaml not found"} @@ -116,6 +396,7 @@ def edit_app(entry: AppEntry): @router.post("/delete_app") def delete_app(entry: AppEntry): + """Legacy endpoint - deletes app from YAML file""" if not APPS_FILE.exists(): return {"error": "apps.yaml not found"} @@ -142,13 +423,18 @@ def delete_app(entry: AppEntry): @router.get("/icon/{filename}") def get_public_icon_url(filename: str): + """Get public URL for an icon from MinIO""" url = f"https://{MINIO_ENDPOINT}/{BUCKET}/{filename}" return JSONResponse(content={"url": url}) +# ============================================================================ +# App Registration +# ============================================================================ + app.include_router(router, prefix="/api") -# ✅ This is the missing part: if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) + diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..85e8cb1 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,77 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime + + +class UserRegister(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + password: str = Field(..., min_length=6) + + +class UserLogin(BaseModel): + username: str + password: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + created_at: datetime + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserResponse + + +class SectionCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + + +class SectionResponse(BaseModel): + id: int + name: str + display_order: int + apps: list = [] + + +class AppCreate(BaseModel): + section_id: int + name: str = Field(..., min_length=1, max_length=100) + url: str + icon: Optional[str] = None + description: Optional[str] = None + + +class AppUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + url: Optional[str] = None + icon: Optional[str] = None + description: Optional[str] = None + section_id: Optional[int] = None + + +class AppResponse(BaseModel): + id: int + section_id: int + name: str + url: str + icon: Optional[str] + description: Optional[str] + display_order: int + + +class AppData(BaseModel): + name: str + icon: str + description: str + url: str + + +class AppEntry(BaseModel): + section: str + app: AppData + original_name: str | None = None diff --git a/backend/requirements.txt b/backend/requirements.txt index 95985fc..cf33e09 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -36,6 +36,9 @@ pycparser==2.22 pycryptodome==3.23.0 pydantic==2.8.0 pydantic_core==2.20.0 +psycopg2-binary==2.9.10 +passlib[bcrypt]==1.7.4 +python-jose[cryptography]==3.3.0 PyGithub==2.3.0 Pygments==2.18.0 PyJWT==2.8.0 diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..8d7f834 --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,75 @@ +-- Create dedicated user for navix application +CREATE USER navix_user WITH PASSWORD 'Aa123456'; + +-- Create database +CREATE DATABASE navix OWNER navix_user; + +-- Grant privileges +GRANT ALL PRIVILEGES ON DATABASE navix TO navix_user; + +-- Connect to navix database +\c navix; + +-- Grant schema privileges +GRANT ALL ON SCHEMA public TO navix_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO navix_user; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO navix_user; + +-- Create users table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create sections table +CREATE TABLE sections ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + display_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, name) +); + +-- Create apps table +CREATE TABLE apps ( + id SERIAL PRIMARY KEY, + section_id INTEGER NOT NULL REFERENCES sections(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + url TEXT NOT NULL, + icon VARCHAR(255), + description TEXT, + display_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for better query performance +CREATE INDEX idx_sections_user_id ON sections(user_id); +CREATE INDEX idx_apps_section_id ON apps(section_id); +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_email ON users(email); + +-- Create updated_at trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Add triggers for updated_at +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_apps_updated_at BEFORE UPDATE ON apps + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Grant privileges on all objects to navix_user +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO navix_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO navix_user; diff --git a/frontend/src/App.css b/frontend/src/App.css index 4cd5e7a..aadef54 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -27,6 +27,45 @@ body { z-index: 100; } +/* User info and logout */ +.user-info { + position: absolute; + top: 1.5rem; + left: 2rem; + display: flex; + align-items: center; + gap: 1rem; + z-index: 100; +} + +.username { + color: #fff; + font-size: 0.9rem; + font-weight: 500; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + backdrop-filter: blur(10px); +} + +.logout-button { + background: rgba(255, 59, 48, 0.9); + border: none; + border-radius: 8px; + padding: 0.5rem 0.75rem; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.logout-button:hover { + background: rgba(255, 59, 48, 1); + transform: translateY(-2px); +} + /* 🔹 מרכז כותרת */ .title-wrapper { width: 100%; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9083b32..785df74 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,9 +5,11 @@ import AppModal from './components/AppModal'; import ConfirmDialog from './components/ConfirmDialog'; import Clock from './components/Clock'; import Calendar from './components/Calendar'; +import Login from './components/Login'; +import Register from './components/Register'; import { ToastContainer, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; -import { IoIosAdd } from 'react-icons/io'; +import { IoIosAdd, IoMdLogOut } from 'react-icons/io'; import CustomToast from './components/CustomToast'; import { fetchSections, @@ -15,6 +17,7 @@ import { editAppInSection, deleteAppFromSection, } from './services/api'; +import { isAuthenticated, logout, getUser } from './services/auth'; function App() { const [sections, setSections] = useState([]); @@ -22,19 +25,54 @@ function App() { const [showAdd, setShowAdd] = useState(false); const [confirmData, setConfirmData] = useState(null); const [searchTerm, setSearchTerm] = useState(''); + const [authenticated, setAuthenticated] = useState(isAuthenticated()); + const [showRegister, setShowRegister] = useState(false); + const [user, setUser] = useState(getUser()); + + const handleLoginSuccess = () => { + setAuthenticated(true); + setUser(getUser()); + toast.success('Welcome back!'); + }; + + const handleRegisterSuccess = () => { + setAuthenticated(true); + setUser(getUser()); + toast.success('Account created successfully!'); + }; + + const handleLogout = () => { + logout(); + setAuthenticated(false); + setUser(null); + setSections([]); + toast.info('Logged out successfully'); + }; const loadSections = () => { + if (!authenticated) return; + fetchSections() .then(data => { - const filtered = (data.sections || []).filter(section => (section.apps?.length ?? 0) > 0); + // Handle both old format {sections: []} and new format [] + const sectionsArray = Array.isArray(data) ? data : (data.sections || []); + const filtered = sectionsArray.filter(section => (section.apps?.length ?? 0) > 0); setSections(filtered); }) - .catch(err => console.error('Failed to fetch sections:', err)); + .catch(err => { + console.error('Failed to fetch sections:', err); + // If unauthorized, might need to re-login + if (err.message.includes('401') || err.message.includes('Unauthorized')) { + handleLogout(); + } + }); }; useEffect(() => { - loadSections(); - }, []); + if (authenticated) { + loadSections(); + } + }, [authenticated]); const handleDelete = (app) => { setConfirmData({ @@ -96,6 +134,14 @@ function App() { ), })).filter(section => section.apps.length > 0); + // Show login/register if not authenticated + if (!authenticated) { + if (showRegister) { + return setShowRegister(false)} />; + } + return setShowRegister(true)} />; + } + return (
@@ -103,6 +149,14 @@ function App() {
+ {/* User info and logout button */} +
+ 👤 {user?.username} + +
+ {/* 🔹 לוגו וכותרת במרכז */}

diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx new file mode 100644 index 0000000..dd8f9b1 --- /dev/null +++ b/frontend/src/components/Login.jsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { login } from '../services/auth'; +import '../style/Auth.css'; + +function Login({ onLoginSuccess, onSwitchToRegister }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await login(username, password); + onLoginSuccess(); + } catch (err) { + setError(err.message || 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Welcome to Navix

+

Sign in to your account

+ +
+ {error &&
{error}
} + +
+ + setUsername(e.target.value)} + placeholder="Enter your username" + required + autoComplete="username" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + autoComplete="current-password" + disabled={loading} + /> +
+ + +
+ +
+

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

+
+
+
+ ); +} + +export default Login; diff --git a/frontend/src/components/Register.jsx b/frontend/src/components/Register.jsx new file mode 100644 index 0000000..9fbd88e --- /dev/null +++ b/frontend/src/components/Register.jsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { register } from '../services/auth'; +import '../style/Auth.css'; + +function Register({ onRegisterSuccess, onSwitchToLogin }) { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + // Validation + if (username.length < 3) { + setError('Username must be at least 3 characters'); + return; + } + + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setLoading(true); + + try { + console.log('Attempting registration...', { username, email }); + await register(username, email, password); + console.log('Registration successful'); + onRegisterSuccess(); + } catch (err) { + console.error('Registration error:', err); + setError(err.message || 'Registration failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Join Navix

+

Create your account

+ +
+ {error &&
{error}
} + +
+ + setUsername(e.target.value)} + placeholder="Choose a username" + required + minLength={3} + autoComplete="username" + disabled={loading} + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="Enter your email" + required + autoComplete="email" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Choose a password" + required + minLength={6} + autoComplete="new-password" + disabled={loading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + required + minLength={6} + autoComplete="new-password" + disabled={loading} + /> +
+ + +
+ +
+

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

+
+
+
+ ); +} + +export default Register; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 5e7f656..224f691 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,7 +1,20 @@ -const API_BASE = window?.ENV?.API_BASE || ""; +const API_BASE = window?.ENV?.API_BASE || "http://localhost:8000/api"; + +// Get auth token from localStorage +function getAuthHeader() { + const token = localStorage.getItem('navix_token'); + console.log('Token for request:', token ? `${token.substring(0, 20)}...` : 'NO TOKEN'); + return token ? { 'Authorization': `Bearer ${token}` } : {}; +} export async function fetchSections() { - const res = await fetch(`${API_BASE}/apps`); + console.log('Fetching sections with auth...'); + const res = await fetch(`${API_BASE}/sections`, { + headers: { + ...getAuthHeader() + } + }); + console.log('Sections response status:', res.status); if (!res.ok) throw new Error('Failed to fetch sections'); return res.json(); } diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js new file mode 100644 index 0000000..e3e4723 --- /dev/null +++ b/frontend/src/services/auth.js @@ -0,0 +1,96 @@ +const API_BASE = window?.ENV?.API_BASE || "http://localhost:8000/api"; + +const TOKEN_KEY = 'navix_token'; +const USER_KEY = 'navix_user'; + +// Store token and user info +export function setAuthData(token, user) { + localStorage.setItem(TOKEN_KEY, token); + localStorage.setItem(USER_KEY, JSON.stringify(user)); +} + +// Get stored token +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +// Get stored user +export function getUser() { + const user = localStorage.getItem(USER_KEY); + return user ? JSON.parse(user) : null; +} + +// Check if user is authenticated +export function isAuthenticated() { + return !!getToken(); +} + +// Clear auth data (logout) +export function clearAuthData() { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); +} + +// Register new user +export async function register(username, email, password) { + const res = await fetch(`${API_BASE}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, email, password }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || 'Registration failed'); + } + + const data = await res.json(); + console.log('Registration response:', data); + console.log('Token:', data.access_token); + setAuthData(data.access_token, data.user); + return data; +} + +// Login user +export async function login(username, password) { + const res = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || 'Login failed'); + } + + const data = await res.json(); + setAuthData(data.access_token, data.user); + return data; +} + +// Logout user +export function logout() { + clearAuthData(); +} + +// Get current user from API +export async function getCurrentUser() { + const token = getToken(); + if (!token) throw new Error('Not authenticated'); + + const res = await fetch(`${API_BASE}/auth/me`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!res.ok) { + if (res.status === 401) { + clearAuthData(); + } + throw new Error('Failed to get user info'); + } + + return res.json(); +} diff --git a/frontend/src/style/Auth.css b/frontend/src/style/Auth.css new file mode 100644 index 0000000..76d6faf --- /dev/null +++ b/frontend/src/style/Auth.css @@ -0,0 +1,169 @@ +.auth-container { + min-height: 100vh; + width: 100vw; + display: flex; + align-items: center; + justify-content: center; + background: #121212; + padding: 2rem; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.auth-card { + background: rgba(30, 30, 30, 0.95); + backdrop-filter: blur(10px); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 3rem; + width: 100%; + max-width: 450px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.auth-title { + font-size: 2.5rem; + font-weight: 700; + text-align: center; + margin: 0 0 0.5rem 0; + color: #ffffff; + font-family: 'Orbitron', 'Segoe UI', sans-serif; +} + +.auth-subtitle { + font-size: 1rem; + font-weight: 400; + text-align: center; + margin: 0 0 2.5rem 0; + color: #a0a0a0; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.auth-error { + background: rgba(255, 59, 48, 0.15); + color: #ff6b6b; + padding: 0.875rem 1rem; + border-radius: 10px; + font-size: 0.875rem; + border: 1px solid rgba(255, 59, 48, 0.3); +} + +.auth-input-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.auth-input-group label { + font-size: 0.875rem; + font-weight: 600; + color: #e0e0e0; +} + +.auth-input-group input { + padding: 0.875rem 1rem; + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + font-size: 1rem; + transition: all 0.2s; + background: rgba(255, 255, 255, 0.05); + color: #ffffff; +} + +.auth-input-group input::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +.auth-input-group input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.08); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05); +} + +.auth-input-group input:disabled { + background: rgba(255, 255, 255, 0.02); + cursor: not-allowed; + opacity: 0.5; +} + +.auth-button { + padding: 1rem 1.5rem; + background: rgba(255, 255, 255, 0.9); + color: #121212; + border: none; + border-radius: 10px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + margin-top: 0.5rem; +} + +.auth-button:hover:not(:disabled) { + background: #ffffff; + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(255, 255, 255, 0.2); +} + +.auth-button:active:not(:disabled) { + transform: translateY(0); +.auth-footer { + margin-top: 2rem; + text-align: center; + padding-top: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.auth-footer p { + margin: 0; + color: #a0a0a0; + font-size: 0.875rem; +} + +.auth-link { + background: none; + border: none; + color: #ffffff; + font-weight: 600; + cursor: pointer; + text-decoration: none; + font-size: 0.875rem; + padding: 0; + transition: color 0.2s; +} + +.auth-link:hover:not(:disabled) { + color: #ffffff; + text-decoration: underline; +} + +.auth-link:disabled { + opacity: 0.5; + cursor: not-allowed; +}auth-link:hover:not(:disabled) { + text-decoration: underline; +} + +.auth-link:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +@media (max-width: 480px) { + .auth-card { + padding: 2rem 1.5rem; + } + + .auth-title { + font-size: 1.5rem; + } +} diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..e266654 --- /dev/null +++ b/values.yaml @@ -0,0 +1,47 @@ +frontend: + image: + repository: harbor.dvirlabs.com/my-apps/navix-frontend + pullPolicy: IfNotPresent + tag: master-e56328b + service: + type: ClusterIP + port: 80 + ingress: + enabled: true + className: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + hosts: + - host: navix.dvirlabs.com + paths: + - path: / + pathType: Prefix + env: + API_BASE: "https://api-navix.dvirlabs.com/api" + MINIO_ENDPOINT: "s3.dvirlabs.com" + MINIO_BUCKET: "navix-icons" +backend: + image: + repository: harbor.dvirlabs.com/my-apps/navix-backend + pullPolicy: IfNotPresent + tag: master-62a2769 + service: + type: ClusterIP + port: 8000 + env: + MINIO_ACCESS_KEY: "your-access-key" + MINIO_SECRET_KEY: "your-secret-key" + MINIO_ENDPOINT: "s3.dvirlabs.com" + MINIO_BUCKET: "navix-icons" + ingress: + enabled: true + className: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + hosts: + - host: api-navix.dvirlabs.com + paths: + - path: /api + pathType: Prefix