Compare commits

...

2 Commits

Author SHA1 Message Date
4fc279e17c Add dev/ 2025-12-10 08:33:04 +02:00
dvirlabs
deaa10181a Add calander 2025-12-10 00:54:39 +02:00
27 changed files with 1683 additions and 30 deletions

View File

@ -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:

View File

@ -1,4 +1,14 @@
MINIO_ACCESS_KEY=TDJvsBmbkpUXpCw5M7LA
MINIO_SECRET_KEY=n9scR7W0MZy6FF0bznV98fSgXpdebIQjqZvEr1Yu
MINIO_ENDPOINT=s3.dvirlabs.com
MINIO_BUCKET=navix-icons
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

15
backend/.env.example Normal file
View File

@ -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

135
backend/README.md Normal file
View File

@ -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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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

81
backend/auth.py Normal file
View File

@ -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"},
)

56
backend/database.py Normal file
View File

@ -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

View File

@ -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)

77
backend/models.py Normal file
View File

@ -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

View File

@ -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

75
backend/schema.sql Normal file
View File

@ -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;

View File

@ -12,10 +12,60 @@ body {
flex-direction: column;
align-items: center;
padding: 2rem;
padding-top: 9rem;
min-height: 100vh;
text-align: center;
}
.top-right-container {
position: absolute;
top: 1.5rem;
right: 2rem;
display: flex;
gap: 1rem;
align-items: flex-start;
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%;

View File

@ -4,9 +4,12 @@ import SectionGrid from './components/SectionGrid';
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,
@ -14,6 +17,7 @@ import {
editAppInSection,
deleteAppFromSection,
} from './services/api';
import { isAuthenticated, logout, getUser } from './services/auth';
function App() {
const [sections, setSections] = useState([]);
@ -21,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({
@ -95,9 +134,28 @@ function App() {
),
})).filter(section => section.apps.length > 0);
// Show login/register if not authenticated
if (!authenticated) {
if (showRegister) {
return <Register onRegisterSuccess={handleRegisterSuccess} onSwitchToLogin={() => setShowRegister(false)} />;
}
return <Login onLoginSuccess={handleLoginSuccess} onSwitchToRegister={() => setShowRegister(true)} />;
}
return (
<div className="App">
<Clock />
<div className="top-right-container">
<Calendar />
<Clock />
</div>
{/* User info and logout button */}
<div className="user-info">
<span className="username">👤 {user?.username}</span>
<button className="logout-button" onClick={handleLogout} title="Logout">
<IoMdLogOut size={20} />
</button>
</div>
{/* 🔹 לוגו וכותרת במרכז */}
<div className="title-wrapper">

View File

@ -0,0 +1,92 @@
import { useState, useEffect } from 'react';
import { FaChevronDown } from 'react-icons/fa';
import '../style/Calendar.css';
function Calendar() {
const [now, setNow] = useState(new Date());
const [collapsed, setCollapsed] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const getDaysInMonth = (date) => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
return { daysInMonth, startingDayOfWeek, year, month };
};
const { daysInMonth, startingDayOfWeek, year, month } = getDaysInMonth(currentMonth);
const days = [];
for (let i = 0; i < startingDayOfWeek; i++) {
days.push(null);
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i);
}
const isToday = (day) => {
return day === now.getDate() &&
month === now.getMonth() &&
year === now.getFullYear();
};
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const prevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1));
};
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1));
};
return (
<div className={`calendar-container ${collapsed ? 'collapsed' : ''}`}>
<div className="calendar-header" onClick={() => setCollapsed(!collapsed)}>
<span className={`calendar-date ${collapsed ? 'collapsed' : ''}`}>{now.toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})}</span>
<FaChevronDown className={`calendar-toggle-icon ${collapsed ? 'collapsed' : ''}`} />
</div>
<div className={`calendar-body ${collapsed ? 'collapsed' : ''}`}>
<div className="calendar-month-nav">
<button onClick={prevMonth} className="calendar-nav-btn"></button>
<span className="calendar-month-year">{monthNames[month]} {year}</span>
<button onClick={nextMonth} className="calendar-nav-btn"></button>
</div>
<div className="calendar-grid">
{dayNames.map(day => (
<div key={day} className="calendar-day-name">{day}</div>
))}
{days.map((day, idx) => (
<div
key={idx}
className={`calendar-day ${day === null ? 'empty' : ''} ${isToday(day) ? 'today' : ''}`}
>
{day}
</div>
))}
</div>
</div>
</div>
);
}
export default Calendar;

View File

@ -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 (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">Welcome to Navix</h1>
<h2 className="auth-subtitle">Sign in to your account</h2>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="auth-error">{error}</div>}
<div className="auth-input-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
autoComplete="username"
disabled={loading}
/>
</div>
<div className="auth-input-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
autoComplete="current-password"
disabled={loading}
/>
</div>
<button type="submit" className="auth-button" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="auth-footer">
<p>
Don't have an account?{' '}
<button
onClick={onSwitchToRegister}
className="auth-link"
disabled={loading}
>
Create one
</button>
</p>
</div>
</div>
</div>
);
}
export default Login;

View File

@ -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 (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">Join Navix</h1>
<h2 className="auth-subtitle">Create your account</h2>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="auth-error">{error}</div>}
<div className="auth-input-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
required
minLength={3}
autoComplete="username"
disabled={loading}
/>
</div>
<div className="auth-input-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
autoComplete="email"
disabled={loading}
/>
</div>
<div className="auth-input-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Choose a password"
required
minLength={6}
autoComplete="new-password"
disabled={loading}
/>
</div>
<div className="auth-input-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
minLength={6}
autoComplete="new-password"
disabled={loading}
/>
</div>
<button type="submit" className="auth-button" disabled={loading}>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="auth-footer">
<p>
Already have an account?{' '}
<button
onClick={onSwitchToLogin}
className="auth-link"
disabled={loading}
>
Sign in
</button>
</p>
</div>
</div>
</div>
);
}
export default Register;

View File

@ -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();
}

View File

@ -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();
}

169
frontend/src/style/Auth.css Normal file
View File

@ -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;
}
}

View File

@ -0,0 +1,153 @@
.calendar-container {
font-family: 'Rajdhani', sans-serif;
color: #00f0ff;
text-shadow: 0 0 6px rgba(0, 255, 255, 0.3);
user-select: none;
background: rgba(0, 20, 40, 0.8);
border: 1px solid rgba(0, 240, 255, 0.3);
border-radius: 10px;
padding: 0.7rem;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s ease;
width: auto;
position: absolute;
right: 200px;
}
.calendar-container.collapsed {
width: auto;
padding: 0.5rem;
background: transparent;
border: none;
backdrop-filter: none;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding-bottom: 0.4rem;
min-width: 30px;
}
.calendar-date {
font-size: 0.85rem;
color: #999;
white-space: nowrap;
overflow: hidden;
transition: all 0.3s ease;
}
.calendar-date.collapsed {
width: 0;
opacity: 0;
}
.calendar-toggle-icon {
font-size: 1.4rem;
color: #00f0ff;
transition: transform 0.3s ease;
flex-shrink: 0;
transform: rotate(-90deg);
}
.calendar-toggle-icon.collapsed {
transform: rotate(90deg);
}
.calendar-body {
width: 220px;
overflow: hidden;
transition: all 0.3s ease;
opacity: 1;
}
.calendar-body.collapsed {
width: 0;
opacity: 0;
}
.calendar-month-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0.5rem 0;
padding-top: 0.4rem;
border-top: 1px solid rgba(0, 240, 255, 0.2);
}
.calendar-month-year {
font-size: 0.9rem;
font-weight: 500;
color: #00f0ff;
}
.calendar-nav-btn {
background: transparent;
border: 1px solid rgba(0, 240, 255, 0.3);
color: #00f0ff;
cursor: pointer;
font-size: 1.2rem;
width: 24px;
height: 24px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
line-height: 1;
}
.calendar-nav-btn:hover {
background: rgba(0, 240, 255, 0.1);
border-color: #00f0ff;
transform: scale(1.1);
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-top: 0.5rem;
}
.calendar-day-name {
text-align: center;
font-size: 0.65rem;
color: #00f0ff;
font-weight: 500;
padding: 0.2rem 0;
opacity: 0.7;
}
.calendar-day {
text-align: center;
padding: 0.35rem;
font-size: 0.75rem;
color: #999;
border-radius: 4px;
transition: all 0.2s ease;
}
.calendar-day:not(.empty):hover {
background: rgba(0, 240, 255, 0.1);
color: #00f0ff;
cursor: pointer;
}
.calendar-day.today {
background: rgba(0, 240, 255, 0.2);
color: #00f0ff;
font-weight: bold;
border: 1px solid #00f0ff;
box-shadow: 0 0 8px rgba(0, 240, 255, 0.4);
}
.calendar-day.empty {
visibility: hidden;
}

View File

@ -1,7 +1,4 @@
.clock-container {
position: absolute;
top: 1.5rem;
right: 2rem;
text-align: right;
font-family: 'Rajdhani', sans-serif;
color: #00f0ff;
@ -9,6 +6,8 @@
font-size: 1rem;
line-height: 1.2;
user-select: none;
position: absolute;
right: 0;
}
.clock-time {

47
values.yaml Normal file
View File

@ -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