From 4fc279e17cad8c885457626bb7431bb9fd1ba982 Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Wed, 10 Dec 2025 08:33:04 +0200 Subject: [PATCH] Add dev/ --- .woodpecker.yaml | 3 + backend/.env | 12 +- backend/.env.example | 15 + backend/README.md | 135 ++++++++ backend/__pycache__/auth.cpython-313.pyc | Bin 0 -> 3993 bytes backend/__pycache__/database.cpython-313.pyc | Bin 0 -> 2501 bytes backend/__pycache__/main.cpython-313.pyc | Bin 7258 -> 18339 bytes backend/__pycache__/models.cpython-313.pyc | Bin 0 -> 3975 bytes backend/apps.yaml | 12 + backend/auth.py | 81 +++++ backend/database.py | 56 ++++ backend/main.py | 322 +++++++++++++++++-- backend/models.py | 77 +++++ backend/requirements.txt | 3 + backend/schema.sql | 75 +++++ frontend/src/App.css | 39 +++ frontend/src/App.jsx | 64 +++- frontend/src/components/Login.jsx | 85 +++++ frontend/src/components/Register.jsx | 138 ++++++++ frontend/src/services/api.js | 17 +- frontend/src/services/auth.js | 96 ++++++ frontend/src/style/Auth.css | 169 ++++++++++ values.yaml | 47 +++ 23 files changed, 1420 insertions(+), 26 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/README.md create mode 100644 backend/__pycache__/auth.cpython-313.pyc create mode 100644 backend/__pycache__/database.cpython-313.pyc create mode 100644 backend/__pycache__/models.cpython-313.pyc create mode 100644 backend/auth.py create mode 100644 backend/database.py create mode 100644 backend/models.py create mode 100644 backend/schema.sql create mode 100644 frontend/src/components/Login.jsx create mode 100644 frontend/src/components/Register.jsx create mode 100644 frontend/src/services/auth.js create mode 100644 frontend/src/style/Auth.css create mode 100644 values.yaml 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 0000000000000000000000000000000000000000..1633cab62ef4422d0322eb98ba4b61f1afeb9f0e GIT binary patch literal 3993 zcma)9T}&I<6~5yc|6}uG{$a@gNr{ss$r4BifkXtn0kaEP58goR$eq{&Ok&2?oiPbK zp=}>XS}EC$qPSJ1B6T0KQXf*aQhC^i?)ITopKK8cG)tRE-IslHL|Rp8A9~K%V@MjM zd!@Pe&b{Xz&pqcm-#Om(cw7Y9ecu`R12-Xm#}~8NOOxF;hL8tDAObT%f`pPFLm3|5 zEfba?OIc&iPH;ghwFYg}X1sF~c4`M1>x3idq|Ts=x`J-%Huh{26+sX61ijQ-lC7kb z(aI2S{`B71K?J+t=(7}8Xtm&^HG+%!qMi=YR}!POf;(C#RKVkj*7w>3Z!@{h(FUQ? zSgGsfgeqgDQD_vZqfJ6hl#Mc0I1Tc3An(K7>kMrcYK;?3aH7sQ(GqobkZ5fmSO8lu zBh*K|a0(?FN~c=qIe*i4xb3&-j)bU5n!G6KPV5s>QWO1*?wCqza!L`CC=$Fp9iTKt zb@uvAZPL%`_E9ME%qB1XaYYVd4Ei2t= zno4Bis71Gq2|azMu2Kw z(Xc8}HJVtGX)-#cNTXCI~ zDw~Zqg3=n)egwKz@~yl2?zubXvd6ZXkLR0@Kelf+_ir5E8n~1nxRm>7W^-V6(|sk! zUD*X`qC3p1#Y=d^Rf0A&tp0PD{C}&PQKK;X{KKK5SDWDsP&3CZvhOPleP z+mnzIDw=@jdD?(m)$@{8HhK(o&~{kIJ@piHWy^26TUJMRJifbE?_ACH|ML9L&ObW6 z*>dvn^rmNE_5E#d&(Q6mY{RCzJ;$}vCe+PAh>N;Mx|Noxq{fU8e{&dk4dMit?{^od zz*csHCCnTyE)fy*N6MiRdZSa!b77_%PATkE=l4AkDLXUbDE%N%`k9g*a3Car*PCDo zvnR>{$&*NhDG~CTv%L`@V~Jqw7%hT0H=4knVHGaS0SLI2CSWVzNPqz_6)(o)lB)7r z3c?0>(!MAw8BJ1k7R(FpPBA$TG^Z^r`dvB~Pog7vB>x;A|B`4zOmT%?IQPzY!~5CT3iQz6UaG@Tk|ITP4q`Y9Bg3b>4Tc zj{nVGyHnlt@W5vWHmf_sFMK}xcXN#-NbG7%R?8ocI}uyK7$j0g&)YE~SOiw!PB4P?3@b5nWQaXL z<`}M|W1{yg~Q_{Q0M&BZnQc4hsV_g_11-!8F>uj>g^UW+)LI-7B7zk_VndKbJmzkgjg)D}%splD1OP^= z_ax0KLSbim-dIQ2O$otq%wmALOjrQw0U%c{QfKNDoMFyOPFl<}0Qm2F7gq#p3ZsQN z^E~R>e@DThaKqeOIUr865ESl}^To;#xMvwSl83Cu1E%?ShEhN_k7b65@C2WMDuth; zsYQcXjj+*KHFIrp<>=ePh3|$)GLFaO1jP(PF-g+X(CH3TtB8wG9O-tPqs%butW;ot z@e-D@1N;jA_TL!b{cd^=wIb+s7Ot`vQ1mRWxnw8@AUKt{1mCSeeJm@Q?%b<~t!WA? zz|@8wj5Rul-32_&NUzg!6&3*{Q^oMhX%Gba;Zgqt-FHUBHCSESRV`apUHPi6HS12z zp>=7q=G`^>vzn%dM?XE9g|~wcfbN>x=XYuj?6h`lwf5&*`?niLx0{YWn#nbug1GZ} zA?`fh7hY0Pe{cR5>Xy4R@9x~GZ_0MvPp(~j*3$8N_ix-A!OasF^Oa*c#~8$N?JluF zEIT~+DzYDCM{-{NiKG3M(5~zIU3{bcw{wqYa=vr_8|D8lSV6&mR6pG16J~^CemvYi zJVd@4#Q9f4t`Ua)+TM3zko|hl3iCfQEUf#PN&h=W4lU+Ro3@;W8jNE2>fH4dSjpfz z1b3+vn-jq&Q0pL1?72*@Dy<)b{}vfY(aRJw0L219x1?0v2G*C9rF|rN1h*Vz@c_PZ z*HWpZQ572n8WdM@Tr+Z~ffK`HUl1d?6?xP#xCnn(lF~UEhV#H!MEwhN1(so$r{wTo zNy}5x_6_koB|Ul4^OOX>APn zTy+#|7AC^1j}!=w8=b}R4+mcu?*$jfbgZ-BX{O^5`vQmOUKi7lO%wq^C#ao z=DAS#AGlU%b}_E3ifgWQ?FA0co5>h6#r*l!=Ea%kq=|9m4v!X$@v8Vw9F4pF7hgkw A1ONa4 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/database.cpython-313.pyc b/backend/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40cf9704498662792288f36486ae8305ad588692 GIT binary patch literal 2501 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 delta 3554 zcmbVOTWl2989rxrc4x1?u338xcx`-PF}}pcU>lpp-0^~ydIF@$gz2z5Hj}PrmNT=V z(1(R06+gAECXxCQ#Zp=+k*X9aFMVtu`W98LEVb4kM5Fehs1M!LyfiQE|DV0s(4eS2 z(){N?-}(P@`7h_6kA6bE$c8yw;=EZ^9`F46mltUx5{)MODNJ?l&4@T1BLHE0~tuZn| zD|)Q0jz#O(o;nt*V~HA8YN%uFN>dL}+LS#?LT;3srbO1Ew9nFm)g$EuLf}r#EU836 zuCu1MIJtA?8Yj05MF?U0l$OQW8n)BRtux*%*(IH7Cp|jB>EM&Xs zkze@nLc|?S4W!AeuZt|vw7B5;b;d0ARVzvj-_}<0DbsRNOIFd$d6c<}qm$j8u#Oj8 z)*Tgv7;bkMK6?(xUGmpR+`Tu|?*4A{NGS_Qy;Wn1Y@Yz=eWDOXWx@oRGG8B2M8z)) zxD@Anbxy2ve&l3$KbEC*(39p0h0~g&Jv{=bCz{%d?uka$GQCJnx3kPBI7SIZz9P$W zV=bg5FQ}?0sH$FARadNBF^_yyRc{uxe08Q#RhJBAJ9)#@O$(-dw!=I?H4`ks?Qa9x zrm8lMva04d%(xD|1(@%|4b8wxCBo8hI!^GIb^^!)vK5Zqzfd8~@l7$l6^j1od_@Vx z|1YBjz%<`m_6M+LwIIYL7yztx#&p>2r^jI@PqOta29)OmPcC>diy6y? zspYwq_+8Q=HUg4t6v-<{#*mC7!7EiRh>J1I`{(aJN!T$2k)Vi;5eWB>HQm3s6+)8g zy?beG{Acl>PXAHtt(X9X-G7b#z8apNzp{_|+)t19hcEkN(GQc0F?Vh3w0mvrHubw( zW8F01el<4L%AZ!43U2MOy?e1r5Un1YkEzE_Mz^m{ztxtGV@=b>sAvL(M>-G-nIfK7u>@}GB4t(}N-Qf480`M^5Te&N`{HE`!P>IJ@a{eYErg&MD zxdRS!d)La2=?9!6t>j9;7Dm9ApaH>SEy?Jlv7|dNGd3jQ>-JU*2yZo|+A^2@)3=}s zL3qnS)92nimT*6q34g64znFQMBfH#**~4T1t25aJ_qVhAO7n28-JXNurK9H}-{3i4 zUN^bfEAIMwPjN>r;tt$V(fUIK${y9gWA580!UuSAgeyDq{I~)t4t#ch&0SD?vY-9q zghERTcY-$@r(jQyjIcep^e~V#Wf!sYG5D2*d_8&jROZa=2>ZYu{6~YA{z?VFSKAW{7Su|7vv?jc^iHKzptZaP z!_4N3IbGEm{}tku1)hL+!Ke&s5G$I`*AF7BVAkuD}Y%3%8tU;u)RpS|f?pcG6_O0H2MxZN4QK|D@=jxlAw7o3EY1i81Hh~Ux zJ2+4tuF1T$NjpkqAwj#>&TkXwP$unR8`1rn zw5u!#ba?&1Hh~U>QXD9U2^IfDTK+&<){d_CZy=Y3yNS`&JDa|aNBhN=Eiv*S^TW(q Vc)jCc{MWfn@l@GQ#3V1a{{d3{@k{^! diff --git a/backend/__pycache__/models.cpython-313.pyc b/backend/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e45d635270f0e9a200c9d15bfe023f0ca5940c55 GIT binary patch literal 3975 zcmbVP-EZ4e6u0AR$8nN2ZPU-uGPV_F*at8TTE<|k8;q8|xR#d1P;UOB>1$H zdH)kllK#XW-H#eC_D;!?^jxweOJ0%|WiBtKcxqAM%1OjhOKef)Dy@|zZBgUeNohhl zC0T4rvQ%5XtOz|kO{fNFx)0R})d9`)p&3FAK(l?QL1+%pd>@)6bO6x7J~T&Y0nnj7 zG*9R-pd)?g0HH-dNBhu0LdO6d??VfOP5?UDhYnSy%oA-SYAR9Yx*K@6>t(MR={Hwh zziNd%Qor`SYT1+{eWell^_pA7b=eKQ&|mdRv90L>j-b!p9k@J~Y>CU3#8Z~cm08JV zVA_C!(bdvtHZlzC@qY%aD66T56e z(8rU>^ygsb&GapWYc@NExcWmK;;oG@8|BNer;oXU1&pVk=kVtJP#sbSRIa~OJ(8`-hdR|9O4&o7X>~i z!0dn|E{rxyzqqd!whMC`H?~givN^#O&mG{Z0Dzyp>mZJD1^0?{-H-L2%P&ei4W)QL z-ZK>MgE9L%UL5iik7qzVM>bKu><5jiyY4_0g&1INqY=c0Fp}b)zl*n0hLa)YPzkF- zMNjnpWju`n9~)qe>PEkj-~6^UGPc9UT0 zAemPemh4qxYQtnn3;vZ*6;6YFZn&Y_UI1+(!1jZZY(vK2Cvh8>!oM^$-NK(J z@ZRnKLBvQ56?a$>GW+o@aSxvSUrc&E#bQuAnZ?ehsYrI2=oMzBBORFBgvn>Uy$BDA z(s4bE6t~e>j`DTx!^G%TX%H8gmN-83by4EC0?HEW+Y)Y|z{dp`0^s0$Ve`9I1_dO= z?dGKy#b=-Ok^&Deo5S$uV7=_tU|i=4AYzWku#PmCaQs?Dq&RRCL$?@Cu}#smLOq;1 z7gbkEX9_fO1-;QBM@9PQ;y=e19REP!gb_Rk@s}dY@*mR0ZRz4`>BMVkx}z!b`R05_ zf=eftl82j_js%yE!Q@HA;nEpURIi kRC!jcXFG+oJl-@r5?ne(Cg->4d>(bqZms_fIz^@b2iv{*^8f$< literal 0 HcmV?d00001 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