Komalpreet Kaur commited on
Commit
06b628f
·
unverified ·
1 Parent(s): 56fba6d

feat: Implement secure Authentication system with JWT and AuthScreen UI

Browse files
app/api/auth_router.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, status, Depends
2
+ from pydantic import BaseModel, field_validator
3
+ import re
4
+
5
+ from app.auth.auth import hash_password, verify_password, create_token, get_current_user
6
+ from app.db.session import create_user, get_user
7
+
8
+ router = APIRouter(prefix="/auth", tags=["auth"])
9
+
10
+
11
+ class AuthRequest(BaseModel):
12
+ username: str
13
+ password: str
14
+
15
+ @field_validator("username")
16
+ @classmethod
17
+ def validate_username(cls, v: str) -> str:
18
+ v = v.strip()
19
+ if not 3 <= len(v) <= 30:
20
+ raise ValueError("Username must be 3–30 characters")
21
+ if not re.match(r"^[a-zA-Z0-9_]+$", v):
22
+ raise ValueError("Username can only contain letters, numbers, and underscores")
23
+ return v
24
+
25
+ @field_validator("password")
26
+ @classmethod
27
+ def validate_password(cls, v: str) -> str:
28
+ if len(v) < 6:
29
+ raise ValueError("Password must be at least 6 characters")
30
+ return v
31
+
32
+
33
+ class TokenResponse(BaseModel):
34
+ access_token: str
35
+ token_type: str = "bearer"
36
+ username: str
37
+
38
+
39
+ @router.post("/register", response_model=TokenResponse)
40
+ async def register(req: AuthRequest):
41
+ hashed = hash_password(req.password)
42
+ created = create_user(req.username, hashed)
43
+ if not created:
44
+ raise HTTPException(
45
+ status_code=status.HTTP_409_CONFLICT,
46
+ detail="Username already taken"
47
+ )
48
+ token = create_token(req.username)
49
+ return TokenResponse(access_token=token, username=req.username)
50
+
51
+
52
+ @router.post("/login", response_model=TokenResponse)
53
+ async def login(req: AuthRequest):
54
+ row = get_user(req.username)
55
+ if not row or not verify_password(req.password, row[1]):
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail="Incorrect username or password"
59
+ )
60
+ token = create_token(req.username)
61
+ return TokenResponse(access_token=token, username=req.username)
62
+
63
+
64
+ @router.get("/me")
65
+ async def me(current_user: str = Depends(get_current_user)):
66
+ return {"username": current_user}
app/api/endpoints.py CHANGED
@@ -1,4 +1,4 @@
1
- from fastapi import APIRouter, HTTPException, Response
2
  from fastapi.responses import StreamingResponse
3
  import json
4
  import asyncio
@@ -11,33 +11,32 @@ from app.services.hippocampus import consolidate_memory
11
  from app.services.neocortex import extract_and_store_knowledge
12
  from app.services.sleep_cycle import run_sleep_cycle
13
  from app.db.neo4j_driver import neo4j_db
14
-
15
  from app.services.vitals import get_brain_vitals
 
16
 
17
  router = APIRouter()
18
 
 
 
 
19
  @router.get("/brain/vitals")
20
- async def fetch_brain_vitals(user_id: str = "default_user"):
21
- """Return metrics for Sensory, Semantic, and Working Memory layers."""
22
- return get_brain_vitals(user_id)
23
 
24
  @router.get("/brain/sparks")
25
- async def fetch_neural_sparks(user_id: str = "default_user", limit: int = 5):
26
- """Retrieve the latest spontaneous background insights."""
27
- return get_recent_sparks(user_id=user_id, limit=limit)
28
 
29
 
30
- # ── Knowledge Graph Endpoints ────────────────────────────────────
31
 
32
  @router.get("/graph")
33
- async def get_knowledge_graph(user_id: str = "default_user"):
34
- """Return all nodes and edges from the Knowledge Graph for visualization."""
35
  if not neo4j_db.driver:
36
  return {"nodes": [], "edges": [], "status": "offline"}
37
 
38
  try:
39
- # Fetch all nodes with their connection counts
40
- # Include legacy nodes (no user_id) alongside user-specific ones
41
  node_query = """
42
  MATCH (n:Entity)
43
  WHERE n.user_id = $user_id OR n.user_id IS NULL
@@ -45,25 +44,18 @@ async def get_knowledge_graph(user_id: str = "default_user"):
45
  RETURN n.name AS id, count(r) AS connections
46
  ORDER BY connections DESC
47
  """
48
- node_results = neo4j_db.query(node_query, {"user_id": user_id}) or []
49
 
50
- # Fetch all edges
51
  edge_query = """
52
  MATCH (s:Entity)-[r]->(t:Entity)
53
  WHERE (s.user_id = $user_id OR s.user_id IS NULL)
54
  AND (t.user_id = $user_id OR t.user_id IS NULL)
55
  RETURN s.name AS source, type(r) AS label, t.name AS target
56
  """
57
- edge_results = neo4j_db.query(edge_query, {"user_id": user_id}) or []
58
 
59
- nodes = [
60
- {"id": r["id"], "label": r["id"], "connections": r["connections"]}
61
- for r in node_results
62
- ]
63
- edges = [
64
- {"source": r["source"], "target": r["target"], "label": r["label"]}
65
- for r in edge_results
66
- ]
67
 
68
  return {"nodes": nodes, "edges": edges, "status": "online"}
69
  except Exception as e:
@@ -71,8 +63,7 @@ async def get_knowledge_graph(user_id: str = "default_user"):
71
 
72
 
73
  @router.get("/graph/stats")
74
- async def get_graph_stats(user_id: str = "default_user"):
75
- """Return aggregate stats about the Knowledge Graph."""
76
  if not neo4j_db.driver:
77
  return {"node_count": 0, "edge_count": 0, "top_entities": [], "status": "offline"}
78
 
@@ -83,7 +74,7 @@ async def get_graph_stats(user_id: str = "default_user"):
83
  OPTIONAL MATCH (n)-[r]->()
84
  RETURN count(DISTINCT n) AS nodes, count(DISTINCT r) AS edges
85
  """
86
- counts = neo4j_db.query(count_query, {"user_id": user_id})
87
  node_count = counts[0]["nodes"] if counts else 0
88
  edge_count = counts[0]["edges"] if counts else 0
89
 
@@ -94,21 +85,18 @@ async def get_graph_stats(user_id: str = "default_user"):
94
  ORDER BY connections DESC
95
  LIMIT 5
96
  """
97
- top_results = neo4j_db.query(top_query, {"user_id": user_id}) or []
98
  top_entities = [{"entity": r["entity"], "connections": r["connections"]} for r in top_results]
99
 
100
- return {
101
- "node_count": node_count,
102
- "edge_count": edge_count,
103
- "top_entities": top_entities,
104
- "status": "online"
105
- }
106
  except Exception as e:
107
  return {"node_count": 0, "edge_count": 0, "top_entities": [], "status": "error", "detail": str(e)}
108
 
 
 
 
109
  class QueryRequest(BaseModel):
110
  text: str
111
- user_id: str = "default_user"
112
 
113
  class QueryResponse(BaseModel):
114
  response: str
@@ -117,47 +105,49 @@ class QueryResponse(BaseModel):
117
  class IngestRequest(BaseModel):
118
  text: str
119
  metadata: Optional[Dict] = None
120
- user_id: str = "default_user"
121
 
122
  class IngestResponse(BaseModel):
123
  message: str
124
  chunks: int
125
 
126
  class ConsolidateRequest(BaseModel):
127
- user_id: str
 
 
 
128
 
129
  @router.post("/consolidate", response_model=IngestResponse)
130
- async def process_consolidation(request: ConsolidateRequest):
131
  try:
132
- chunks, msg = consolidate_memory(request.user_id)
133
- # Assuming consolidate_memory returns chunks > 0 if there was memory
134
  if chunks > 0:
135
- history = get_recent_messages(request.user_id, exchanges=50)
136
  doc = "\n".join([f"{m['role']}: {m['content']}" for m in history])
137
- triples = extract_and_store_knowledge(doc, request.user_id)
138
  msg += f" Extracted {triples} graph relations."
139
-
140
- return IngestResponse(
141
- message=msg,
142
- chunks=chunks
143
- )
144
  except Exception as e:
145
  raise HTTPException(status_code=500, detail=str(e))
146
 
 
 
 
147
  @router.post("/sleep")
148
- async def process_sleep_cycle():
149
- """Trigger one full Sleep Cycle — summarize, store, and prune."""
150
  try:
151
  report = run_sleep_cycle(keep_recent=10)
152
  return report
153
  except Exception as e:
154
  raise HTTPException(status_code=500, detail=str(e))
155
 
 
 
 
156
  @router.post("/ingest", response_model=IngestResponse)
157
- async def process_ingest(request: IngestRequest):
158
  try:
159
- num_chunks = ingest_text(request.text, request.metadata, request.user_id)
160
- triples = extract_and_store_knowledge(request.text, request.user_id)
161
  return IngestResponse(
162
  message=f"Sensory data ingested. Extracted {triples} graph relations.",
163
  chunks=num_chunks
@@ -165,18 +155,17 @@ async def process_ingest(request: IngestRequest):
165
  except Exception as e:
166
  raise HTTPException(status_code=500, detail=str(e))
167
 
 
 
 
168
  @router.post("/query/stream")
169
- async def process_query_stream(request: QueryRequest):
170
- """
171
- Stream the cognitive process and finally the response as Server-Sent Events.
172
- """
173
  async def event_generator():
174
  try:
175
- # Step 1: Initial State
176
- history = get_recent_messages(request.user_id, exchanges=5)
177
  state_input = {
178
  "input": request.text,
179
- "user_id": request.user_id,
180
  "chat_history": history,
181
  "context": [],
182
  "graph_context": [],
@@ -184,44 +173,35 @@ async def process_query_stream(request: QueryRequest):
184
  "response": ""
185
  }
186
 
187
- # Send Initial Perception Trace
188
  perception_msg = f"Processing query: {request.text[:50]}..."
189
  yield f"event: trace\ndata: {json.dumps({'phase': 'perception', 'message': perception_msg})}\n\n"
190
  await asyncio.sleep(0.1)
191
 
192
- # Step 2: Stream LangGraph Execution
193
- # orchestrator.stream is a sync iterator, so we use it in a thread or just run it if it's fast enough
194
- # For simplicity in this demo, we use the stream directly
195
  for output in orchestrator.stream(state_input):
196
  for node_name, node_output in output.items():
197
  if node_name == "reflect":
198
- # Send Internal Reflection Trace
199
  reflection = node_output.get("reflection", "")
200
  yield f"event: reflection\ndata: {json.dumps({'message': reflection})}\n\n"
201
- await asyncio.sleep(0.3) # Give user time to read the 'thought'
202
-
203
  elif node_name == "retrieve":
204
- # Send Recall & Association Trace
205
  trace_data = node_output.get("trace_data", {})
206
  recall_msg = f"Found {trace_data.get('sensory_count')} sensory memories."
207
  assoc_msg = f"Extracted {trace_data.get('graph_count')} graph relations."
208
-
209
  yield f"event: trace\ndata: {json.dumps({'phase': 'recall', 'message': recall_msg, 'data': node_output.get('context')})}\n\n"
210
  await asyncio.sleep(0.2)
211
  yield f"event: trace\ndata: {json.dumps({'phase': 'association', 'message': assoc_msg, 'data': node_output.get('graph_context'), 'touched': trace_data.get('touched')})}\n\n"
212
  await asyncio.sleep(0.2)
213
-
214
  elif node_name == "call_model":
215
- # Send Reasoning & Final Trace
216
  reason_msg = "Synthesizing final response via Cortex Node..."
217
  yield f"event: trace\ndata: {json.dumps({'phase': 'reasoning', 'message': reason_msg})}\n\n"
218
  await asyncio.sleep(0.1)
219
-
220
  final_response = node_output.get("response", "")
221
- # Save to Working Memory
222
- add_message(request.user_id, "user", request.text)
223
- add_message(request.user_id, "assistant", final_response)
224
-
225
  yield f"event: final_result\ndata: {json.dumps({'response': final_response})}\n\n"
226
 
227
  except Exception as e:
@@ -229,43 +209,13 @@ async def process_query_stream(request: QueryRequest):
229
 
230
  return StreamingResponse(event_generator(), media_type="text/event-stream")
231
 
232
- @router.post("/query", response_model=QueryResponse)
233
- async def process_query(request: QueryRequest):
234
- try:
235
- # Step 3: Working Memory - Load the last 5 exchanges
236
- history = get_recent_messages(request.user_id, exchanges=5)
237
-
238
- # Step 2: Sensory Memory Logic - invoke the retrieval-augmented graph
239
- state_input = {
240
- "input": request.text,
241
- "user_id": request.user_id,
242
- "chat_history": history,
243
- "context": [],
244
- "graph_context": [],
245
- "response": ""
246
- }
247
-
248
- # Run the graph
249
- result = orchestrator.invoke(state_input)
250
- final_response = result.get("response", "No response generated.")
251
-
252
- # Step 3 (cont): Save the new exchange to Working Memory
253
- add_message(request.user_id, "user", request.text)
254
- add_message(request.user_id, "assistant", final_response)
255
-
256
- return QueryResponse(
257
- response=final_response,
258
- sources=["Step 2: Sensory Memory (ChromaDB)", "Step 3: Working Memory (SQLite)"]
259
- )
260
- except Exception as e:
261
- raise HTTPException(status_code=500, detail=str(e))
262
 
263
  @router.get("/history")
264
- async def fetch_chat_history(user_id: str = "default_user"):
265
- """Fetch the recent SQLite conversation when switching personas."""
266
  try:
267
- history = get_recent_messages(user_id, exchanges=20)
268
  return {"messages": history}
269
  except Exception as e:
270
  raise HTTPException(status_code=500, detail=str(e))
271
-
 
1
+ from fastapi import APIRouter, HTTPException, Depends
2
  from fastapi.responses import StreamingResponse
3
  import json
4
  import asyncio
 
11
  from app.services.neocortex import extract_and_store_knowledge
12
  from app.services.sleep_cycle import run_sleep_cycle
13
  from app.db.neo4j_driver import neo4j_db
 
14
  from app.services.vitals import get_brain_vitals
15
+ from app.auth.auth import get_current_user
16
 
17
  router = APIRouter()
18
 
19
+
20
+ # ── Brain Vitals ─────────────────────────────────────────────────
21
+
22
  @router.get("/brain/vitals")
23
+ async def fetch_brain_vitals(current_user: str = Depends(get_current_user)):
24
+ return get_brain_vitals(current_user)
25
+
26
 
27
  @router.get("/brain/sparks")
28
+ async def fetch_neural_sparks(limit: int = 5, current_user: str = Depends(get_current_user)):
29
+ return get_recent_sparks(user_id=current_user, limit=limit)
 
30
 
31
 
32
+ # ── Knowledge Graph ───────────────────────────────────────────────
33
 
34
  @router.get("/graph")
35
+ async def get_knowledge_graph(current_user: str = Depends(get_current_user)):
 
36
  if not neo4j_db.driver:
37
  return {"nodes": [], "edges": [], "status": "offline"}
38
 
39
  try:
 
 
40
  node_query = """
41
  MATCH (n:Entity)
42
  WHERE n.user_id = $user_id OR n.user_id IS NULL
 
44
  RETURN n.name AS id, count(r) AS connections
45
  ORDER BY connections DESC
46
  """
47
+ node_results = neo4j_db.query(node_query, {"user_id": current_user}) or []
48
 
 
49
  edge_query = """
50
  MATCH (s:Entity)-[r]->(t:Entity)
51
  WHERE (s.user_id = $user_id OR s.user_id IS NULL)
52
  AND (t.user_id = $user_id OR t.user_id IS NULL)
53
  RETURN s.name AS source, type(r) AS label, t.name AS target
54
  """
55
+ edge_results = neo4j_db.query(edge_query, {"user_id": current_user}) or []
56
 
57
+ nodes = [{"id": r["id"], "label": r["id"], "connections": r["connections"]} for r in node_results]
58
+ edges = [{"source": r["source"], "target": r["target"], "label": r["label"]} for r in edge_results]
 
 
 
 
 
 
59
 
60
  return {"nodes": nodes, "edges": edges, "status": "online"}
61
  except Exception as e:
 
63
 
64
 
65
  @router.get("/graph/stats")
66
+ async def get_graph_stats(current_user: str = Depends(get_current_user)):
 
67
  if not neo4j_db.driver:
68
  return {"node_count": 0, "edge_count": 0, "top_entities": [], "status": "offline"}
69
 
 
74
  OPTIONAL MATCH (n)-[r]->()
75
  RETURN count(DISTINCT n) AS nodes, count(DISTINCT r) AS edges
76
  """
77
+ counts = neo4j_db.query(count_query, {"user_id": current_user})
78
  node_count = counts[0]["nodes"] if counts else 0
79
  edge_count = counts[0]["edges"] if counts else 0
80
 
 
85
  ORDER BY connections DESC
86
  LIMIT 5
87
  """
88
+ top_results = neo4j_db.query(top_query, {"user_id": current_user}) or []
89
  top_entities = [{"entity": r["entity"], "connections": r["connections"]} for r in top_results]
90
 
91
+ return {"node_count": node_count, "edge_count": edge_count, "top_entities": top_entities, "status": "online"}
 
 
 
 
 
92
  except Exception as e:
93
  return {"node_count": 0, "edge_count": 0, "top_entities": [], "status": "error", "detail": str(e)}
94
 
95
+
96
+ # ── Request / Response Models ─────────────────────────────────────
97
+
98
  class QueryRequest(BaseModel):
99
  text: str
 
100
 
101
  class QueryResponse(BaseModel):
102
  response: str
 
105
  class IngestRequest(BaseModel):
106
  text: str
107
  metadata: Optional[Dict] = None
 
108
 
109
  class IngestResponse(BaseModel):
110
  message: str
111
  chunks: int
112
 
113
  class ConsolidateRequest(BaseModel):
114
+ pass # user_id now comes from token
115
+
116
+
117
+ # ── Consolidate ───────────────────────────────────────────────────
118
 
119
  @router.post("/consolidate", response_model=IngestResponse)
120
+ async def process_consolidation(current_user: str = Depends(get_current_user)):
121
  try:
122
+ chunks, msg = consolidate_memory(current_user)
 
123
  if chunks > 0:
124
+ history = get_recent_messages(current_user, exchanges=50)
125
  doc = "\n".join([f"{m['role']}: {m['content']}" for m in history])
126
+ triples = extract_and_store_knowledge(doc, current_user)
127
  msg += f" Extracted {triples} graph relations."
128
+ return IngestResponse(message=msg, chunks=chunks)
 
 
 
 
129
  except Exception as e:
130
  raise HTTPException(status_code=500, detail=str(e))
131
 
132
+
133
+ # ── Sleep ─────────────────────────────────────────────────────────
134
+
135
  @router.post("/sleep")
136
+ async def process_sleep_cycle(current_user: str = Depends(get_current_user)):
 
137
  try:
138
  report = run_sleep_cycle(keep_recent=10)
139
  return report
140
  except Exception as e:
141
  raise HTTPException(status_code=500, detail=str(e))
142
 
143
+
144
+ # ── Ingest ────────────────────────────────────────────────────────
145
+
146
  @router.post("/ingest", response_model=IngestResponse)
147
+ async def process_ingest(request: IngestRequest, current_user: str = Depends(get_current_user)):
148
  try:
149
+ num_chunks = ingest_text(request.text, request.metadata, current_user)
150
+ triples = extract_and_store_knowledge(request.text, current_user)
151
  return IngestResponse(
152
  message=f"Sensory data ingested. Extracted {triples} graph relations.",
153
  chunks=num_chunks
 
155
  except Exception as e:
156
  raise HTTPException(status_code=500, detail=str(e))
157
 
158
+
159
+ # ── Stream Query ──────────────────────────────────────────────────
160
+
161
  @router.post("/query/stream")
162
+ async def process_query_stream(request: QueryRequest, current_user: str = Depends(get_current_user)):
 
 
 
163
  async def event_generator():
164
  try:
165
+ history = get_recent_messages(current_user, exchanges=5)
 
166
  state_input = {
167
  "input": request.text,
168
+ "user_id": current_user,
169
  "chat_history": history,
170
  "context": [],
171
  "graph_context": [],
 
173
  "response": ""
174
  }
175
 
 
176
  perception_msg = f"Processing query: {request.text[:50]}..."
177
  yield f"event: trace\ndata: {json.dumps({'phase': 'perception', 'message': perception_msg})}\n\n"
178
  await asyncio.sleep(0.1)
179
 
 
 
 
180
  for output in orchestrator.stream(state_input):
181
  for node_name, node_output in output.items():
182
  if node_name == "reflect":
 
183
  reflection = node_output.get("reflection", "")
184
  yield f"event: reflection\ndata: {json.dumps({'message': reflection})}\n\n"
185
+ await asyncio.sleep(0.3)
186
+
187
  elif node_name == "retrieve":
 
188
  trace_data = node_output.get("trace_data", {})
189
  recall_msg = f"Found {trace_data.get('sensory_count')} sensory memories."
190
  assoc_msg = f"Extracted {trace_data.get('graph_count')} graph relations."
 
191
  yield f"event: trace\ndata: {json.dumps({'phase': 'recall', 'message': recall_msg, 'data': node_output.get('context')})}\n\n"
192
  await asyncio.sleep(0.2)
193
  yield f"event: trace\ndata: {json.dumps({'phase': 'association', 'message': assoc_msg, 'data': node_output.get('graph_context'), 'touched': trace_data.get('touched')})}\n\n"
194
  await asyncio.sleep(0.2)
195
+
196
  elif node_name == "call_model":
 
197
  reason_msg = "Synthesizing final response via Cortex Node..."
198
  yield f"event: trace\ndata: {json.dumps({'phase': 'reasoning', 'message': reason_msg})}\n\n"
199
  await asyncio.sleep(0.1)
200
+
201
  final_response = node_output.get("response", "")
202
+ add_message(current_user, "user", request.text)
203
+ add_message(current_user, "assistant", final_response)
204
+
 
205
  yield f"event: final_result\ndata: {json.dumps({'response': final_response})}\n\n"
206
 
207
  except Exception as e:
 
209
 
210
  return StreamingResponse(event_generator(), media_type="text/event-stream")
211
 
212
+
213
+ # ── History ───────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
  @router.get("/history")
216
+ async def fetch_chat_history(current_user: str = Depends(get_current_user)):
 
217
  try:
218
+ history = get_recent_messages(current_user, exchanges=20)
219
  return {"messages": history}
220
  except Exception as e:
221
  raise HTTPException(status_code=500, detail=str(e))
 
app/auth/auth.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ from typing import Optional
3
+
4
+ from fastapi import Depends, HTTPException, status
5
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
6
+ from jose import JWTError, jwt
7
+ from passlib.context import CryptContext
8
+
9
+ from app.core.config import settings
10
+
11
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
12
+ bearer_scheme = HTTPBearer()
13
+
14
+
15
+ # ── Password helpers ──────────────────────────────────────────────
16
+
17
+ def hash_password(plain: str) -> str:
18
+ return pwd_context.hash(plain)
19
+
20
+
21
+ def verify_password(plain: str, hashed: str) -> bool:
22
+ return pwd_context.verify(plain, hashed)
23
+
24
+
25
+ # ── JWT helpers ───────────────────────────────────────────────────
26
+
27
+ def create_token(username: str) -> str:
28
+ expire = datetime.utcnow() + timedelta(days=settings.JWT_EXPIRE_DAYS)
29
+ payload = {"sub": username, "exp": expire}
30
+ return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
31
+
32
+
33
+ def decode_token(token: str) -> Optional[str]:
34
+ """Return username from a valid token, or None."""
35
+ try:
36
+ payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
37
+ return payload.get("sub")
38
+ except JWTError:
39
+ return None
40
+
41
+
42
+ # ── FastAPI dependency ────────────────────────────────────────────
43
+
44
+ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> str:
45
+ """
46
+ Validates the Bearer token and returns the username (used as user_id).
47
+ Raises 401 if the token is missing, expired, or invalid.
48
+ """
49
+ username = decode_token(credentials.credentials)
50
+ if not username:
51
+ raise HTTPException(
52
+ status_code=status.HTTP_401_UNAUTHORIZED,
53
+ detail="Invalid or expired token",
54
+ headers={"WWW-Authenticate": "Bearer"},
55
+ )
56
+ return username
app/core/config.py CHANGED
@@ -32,6 +32,11 @@ class Settings(BaseSettings):
32
  # SQLite Path
33
  SQLITE_DB_PATH: str = os.getenv("SQLITE_DB_PATH", os.path.join(os.getcwd(), "data", "soma_sessions.db"))
34
 
 
 
 
 
 
35
  model_config = SettingsConfigDict(case_sensitive=True)
36
 
37
  @lru_cache()
 
32
  # SQLite Path
33
  SQLITE_DB_PATH: str = os.getenv("SQLITE_DB_PATH", os.path.join(os.getcwd(), "data", "soma_sessions.db"))
34
 
35
+ # Auth
36
+ JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "change-this-in-production-please")
37
+ JWT_ALGORITHM: str = "HS256"
38
+ JWT_EXPIRE_DAYS: int = 30
39
+
40
  model_config = SettingsConfigDict(case_sensitive=True)
41
 
42
  @lru_cache()
app/db/session.py CHANGED
@@ -5,6 +5,14 @@ DB_PATH = settings.SQLITE_DB_PATH
5
 
6
  def init_session_db():
7
  with sqlite3.connect(DB_PATH) as db:
 
 
 
 
 
 
 
 
8
  db.execute('''
9
  CREATE TABLE IF NOT EXISTS messages (
10
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -23,13 +31,37 @@ def init_session_db():
23
  timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
24
  )
25
  ''')
26
- # Migration: add user_id to existing databses gracefully
27
  try:
28
  db.execute("ALTER TABLE neural_sparks ADD COLUMN user_id TEXT DEFAULT 'default_user'")
29
  except sqlite3.OperationalError:
30
  pass
31
  db.commit()
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  def add_message(session_id: str, role: str, content: str):
34
  """Save a single message to the Working Memory."""
35
  with sqlite3.connect(DB_PATH) as db:
 
5
 
6
  def init_session_db():
7
  with sqlite3.connect(DB_PATH) as db:
8
+ db.execute('''
9
+ CREATE TABLE IF NOT EXISTS users (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ username TEXT UNIQUE NOT NULL,
12
+ hashed_password TEXT NOT NULL,
13
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
14
+ )
15
+ ''')
16
  db.execute('''
17
  CREATE TABLE IF NOT EXISTS messages (
18
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
31
  timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
32
  )
33
  ''')
34
+ # Migration: add user_id to existing databases gracefully
35
  try:
36
  db.execute("ALTER TABLE neural_sparks ADD COLUMN user_id TEXT DEFAULT 'default_user'")
37
  except sqlite3.OperationalError:
38
  pass
39
  db.commit()
40
 
41
+ # ── User account helpers ──────────────────────────────────────────
42
+
43
+ def create_user(username: str, hashed_password: str) -> bool:
44
+ """Insert a new user. Returns False if username already taken."""
45
+ try:
46
+ with sqlite3.connect(DB_PATH) as db:
47
+ db.execute(
48
+ 'INSERT INTO users (username, hashed_password) VALUES (?, ?)',
49
+ (username, hashed_password)
50
+ )
51
+ db.commit()
52
+ return True
53
+ except sqlite3.IntegrityError:
54
+ return False
55
+
56
+ def get_user(username: str):
57
+ """Return (username, hashed_password) row or None."""
58
+ with sqlite3.connect(DB_PATH) as db:
59
+ cursor = db.execute(
60
+ 'SELECT username, hashed_password FROM users WHERE username = ?',
61
+ (username,)
62
+ )
63
+ return cursor.fetchone()
64
+
65
  def add_message(session_id: str, role: str, content: str):
66
  """Save a single message to the Working Memory."""
67
  with sqlite3.connect(DB_PATH) as db:
app/main.py CHANGED
@@ -2,6 +2,7 @@ from fastapi import FastAPI
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from app.core.config import settings
4
  from app.api.endpoints import router as api_router
 
5
  from app.db.session import init_session_db
6
  from app.services.dreaming import idle_brain_cycle
7
  import asyncio
@@ -27,7 +28,8 @@ app.add_middleware(
27
  allow_headers=["*"],
28
  )
29
 
30
- # Include the API router
 
31
  app.include_router(api_router, prefix=settings.API_V1_STR)
32
 
33
  # Serve React frontend if built
 
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from app.core.config import settings
4
  from app.api.endpoints import router as api_router
5
+ from app.api.auth_router import router as auth_router
6
  from app.db.session import init_session_db
7
  from app.services.dreaming import idle_brain_cycle
8
  import asyncio
 
28
  allow_headers=["*"],
29
  )
30
 
31
+ # Include routers
32
+ app.include_router(auth_router, prefix=settings.API_V1_STR)
33
  app.include_router(api_router, prefix=settings.API_V1_STR)
34
 
35
  # Serve React frontend if built
frontend/src/App.jsx CHANGED
@@ -4,11 +4,14 @@ import BrainProcess from './components/BrainProcess'
4
  import KnowledgeGraph from './components/KnowledgeGraph'
5
  import DreamSequence from './components/DreamSequence'
6
  import Onboarding from './components/Onboarding'
 
 
7
  import './App.css'
8
 
9
  function App() {
10
- const [messages, setMessages] = useState([])
11
- const [brainState, setBrainState] = useState({
 
12
  sensoryDocuments: 0,
13
  graphRelations: 0,
14
  workingMemory: 0,
@@ -20,17 +23,16 @@ function App() {
20
  cognitiveState: 'IDLE',
21
  traces: []
22
  })
23
- const [rightPanel, setRightPanel] = useState('graph')
24
- const [currentPersona, setCurrentPersona] = useState('User_Alpha')
25
- const [showOnboarding, setShowOnboarding] = useState(false)
26
- const [theme, setTheme] = useState(() => localStorage.getItem('soma_theme') || 'dark')
27
- const [graphRefreshTick, setGraphRefreshTick] = useState(0) // increments → graph re-fetches
28
- const [sleepModal, setSleepModal] = useState(null) // null | { pruned, relations }
29
 
30
  // ── Resizable columns ──
31
  const [colWidths, setColWidths] = useState([30, 32, 38])
32
- const dragRef = useRef(null)
33
- const layoutRef = useRef(null)
34
 
35
  const startDrag = useCallback((dividerIndex, e) => {
36
  e.preventDefault()
@@ -68,19 +70,22 @@ function App() {
68
  localStorage.setItem('soma_theme', theme)
69
  }, [theme])
70
 
71
- // ── First visit ──
72
  useEffect(() => {
73
- if (!localStorage.getItem('soma_visited')) {
74
  setShowOnboarding(true)
75
  localStorage.setItem('soma_visited', 'true')
76
  }
77
- }, [])
78
 
79
- // ── Poll vitals & sparks ──
80
  useEffect(() => {
 
 
81
  const fetchVitals = async () => {
82
  try {
83
- const res = await fetch(`/api/v1/brain/vitals?user_id=${currentPersona}`)
 
84
  const data = await res.json()
85
  setBrainState(prev => ({
86
  ...prev,
@@ -91,35 +96,40 @@ function App() {
91
  }))
92
  } catch { /* backend offline */ }
93
  }
 
94
  const fetchSparks = async () => {
95
  try {
96
- const res = await fetch(`/api/v1/brain/sparks?user_id=${currentPersona}`)
 
97
  const data = await res.json()
98
  setBrainState(prev => ({ ...prev, sparks: data }))
99
  } catch { /* silent */ }
100
  }
 
101
  fetchVitals(); fetchSparks()
102
  const id = setInterval(() => { fetchVitals(); fetchSparks() }, 20000)
103
  return () => clearInterval(id)
104
- }, [currentPersona])
105
 
106
- // ── Chat history on persona change ──
107
  useEffect(() => {
 
108
  const load = async () => {
109
  try {
110
- const res = await fetch(`/api/v1/history?user_id=${currentPersona}`)
 
111
  const data = await res.json()
112
  setMessages(data.messages || [])
113
  } catch { /* silent */ }
114
  }
115
  load()
116
- }, [currentPersona])
117
 
118
  // ── Sleep ──
119
  const handleSleep = async () => {
120
  setBrainState(prev => ({ ...prev, isLoading: true, cognitiveState: 'SLEEPING' }))
121
  try {
122
- const res = await fetch('/api/v1/sleep', { method: 'POST' })
123
  const data = await res.json()
124
  setBrainState(prev => ({ ...prev, isLoading: false, cognitiveState: 'IDLE', statusMessage: 'Memory consolidated.' }))
125
  setSleepModal({ pruned: data.messages_pruned ?? 0, relations: data.graph_relations_extracted ?? 0 })
@@ -128,11 +138,34 @@ function App() {
128
  }
129
  }
130
 
131
- // ── Called by ChatPanel after each AI response ──
132
  const handleChatComplete = useCallback(() => {
133
  setGraphRefreshTick(t => t + 1)
134
  }, [])
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  return (
137
  <div className={`app-root state-${brainState.cognitiveState.toLowerCase()}`}>
138
 
@@ -188,11 +221,11 @@ function App() {
188
  Sleep
189
  </button>
190
 
191
- <select className="persona-select t-label" value={currentPersona} onChange={e => setCurrentPersona(e.target.value)}>
192
- <option value="User_Alpha">Alpha</option>
193
- <option value="User_Beta">Beta</option>
194
- <option value="System_Admin">Admin</option>
195
- </select>
196
 
197
  <button className="theme-toggle" onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} title="Toggle theme">
198
  {theme === 'dark' ? (
@@ -224,7 +257,7 @@ function App() {
224
  brainState={brainState}
225
  setBrainState={setBrainState}
226
  isLoading={brainState.isLoading}
227
- currentPersona={currentPersona}
228
  onChatComplete={handleChatComplete}
229
  />
230
  </div>
@@ -244,7 +277,7 @@ function App() {
244
  {rightPanel === 'graph'
245
  ? <KnowledgeGraph
246
  highlightedNodes={brainState.highlightedNodes}
247
- currentPersona={currentPersona}
248
  refreshTick={graphRefreshTick}
249
  />
250
  : <DreamSequence sparks={brainState.sparks} />
 
4
  import KnowledgeGraph from './components/KnowledgeGraph'
5
  import DreamSequence from './components/DreamSequence'
6
  import Onboarding from './components/Onboarding'
7
+ import AuthScreen from './components/AuthScreen'
8
+ import { apiFetch } from './api'
9
  import './App.css'
10
 
11
  function App() {
12
+ const [currentUser, setCurrentUser] = useState(() => localStorage.getItem('soma_username') || null)
13
+ const [messages, setMessages] = useState([])
14
+ const [brainState, setBrainState] = useState({
15
  sensoryDocuments: 0,
16
  graphRelations: 0,
17
  workingMemory: 0,
 
23
  cognitiveState: 'IDLE',
24
  traces: []
25
  })
26
+ const [rightPanel, setRightPanel] = useState('graph')
27
+ const [showOnboarding, setShowOnboarding] = useState(false)
28
+ const [theme, setTheme] = useState(() => localStorage.getItem('soma_theme') || 'dark')
29
+ const [graphRefreshTick, setGraphRefreshTick] = useState(0)
30
+ const [sleepModal, setSleepModal] = useState(null)
 
31
 
32
  // ── Resizable columns ──
33
  const [colWidths, setColWidths] = useState([30, 32, 38])
34
+ const dragRef = useRef(null)
35
+ const layoutRef = useRef(null)
36
 
37
  const startDrag = useCallback((dividerIndex, e) => {
38
  e.preventDefault()
 
70
  localStorage.setItem('soma_theme', theme)
71
  }, [theme])
72
 
73
+ // ── First visit onboarding ──
74
  useEffect(() => {
75
+ if (currentUser && !localStorage.getItem('soma_visited')) {
76
  setShowOnboarding(true)
77
  localStorage.setItem('soma_visited', 'true')
78
  }
79
+ }, [currentUser])
80
 
81
+ // ── Poll vitals & sparks (only when logged in) ──
82
  useEffect(() => {
83
+ if (!currentUser) return
84
+
85
  const fetchVitals = async () => {
86
  try {
87
+ const res = await apiFetch(`/api/v1/brain/vitals`)
88
+ if (!res.ok) return
89
  const data = await res.json()
90
  setBrainState(prev => ({
91
  ...prev,
 
96
  }))
97
  } catch { /* backend offline */ }
98
  }
99
+
100
  const fetchSparks = async () => {
101
  try {
102
+ const res = await apiFetch(`/api/v1/brain/sparks`)
103
+ if (!res.ok) return
104
  const data = await res.json()
105
  setBrainState(prev => ({ ...prev, sparks: data }))
106
  } catch { /* silent */ }
107
  }
108
+
109
  fetchVitals(); fetchSparks()
110
  const id = setInterval(() => { fetchVitals(); fetchSparks() }, 20000)
111
  return () => clearInterval(id)
112
+ }, [currentUser])
113
 
114
+ // ── Chat history on login ──
115
  useEffect(() => {
116
+ if (!currentUser) return
117
  const load = async () => {
118
  try {
119
+ const res = await apiFetch(`/api/v1/history`)
120
+ if (!res.ok) return
121
  const data = await res.json()
122
  setMessages(data.messages || [])
123
  } catch { /* silent */ }
124
  }
125
  load()
126
+ }, [currentUser])
127
 
128
  // ── Sleep ──
129
  const handleSleep = async () => {
130
  setBrainState(prev => ({ ...prev, isLoading: true, cognitiveState: 'SLEEPING' }))
131
  try {
132
+ const res = await apiFetch('/api/v1/sleep', { method: 'POST' })
133
  const data = await res.json()
134
  setBrainState(prev => ({ ...prev, isLoading: false, cognitiveState: 'IDLE', statusMessage: 'Memory consolidated.' }))
135
  setSleepModal({ pruned: data.messages_pruned ?? 0, relations: data.graph_relations_extracted ?? 0 })
 
138
  }
139
  }
140
 
141
+ // ── Graph refresh after chat ──
142
  const handleChatComplete = useCallback(() => {
143
  setGraphRefreshTick(t => t + 1)
144
  }, [])
145
 
146
+ // ── Auth ──
147
+ const handleAuth = (username) => {
148
+ setCurrentUser(username)
149
+ setMessages([])
150
+ }
151
+
152
+ const handleLogout = () => {
153
+ localStorage.removeItem('soma_token')
154
+ localStorage.removeItem('soma_username')
155
+ setCurrentUser(null)
156
+ setMessages([])
157
+ setBrainState(prev => ({ ...prev, sensoryDocuments: 0, graphRelations: 0, workingMemory: 0, sparks: [] }))
158
+ }
159
+
160
+ // ── Show auth screen if not logged in ──
161
+ if (!currentUser) {
162
+ return (
163
+ <div data-theme={theme}>
164
+ <AuthScreen onAuth={handleAuth} />
165
+ </div>
166
+ )
167
+ }
168
+
169
  return (
170
  <div className={`app-root state-${brainState.cognitiveState.toLowerCase()}`}>
171
 
 
221
  Sleep
222
  </button>
223
 
224
+ {/* Logged-in user + logout */}
225
+ <div className="user-chip">
226
+ <span className="user-chip-name t-label">{currentUser}</span>
227
+ <button className="user-logout-btn t-label" onClick={handleLogout} title="Sign out"></button>
228
+ </div>
229
 
230
  <button className="theme-toggle" onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} title="Toggle theme">
231
  {theme === 'dark' ? (
 
257
  brainState={brainState}
258
  setBrainState={setBrainState}
259
  isLoading={brainState.isLoading}
260
+ currentUser={currentUser}
261
  onChatComplete={handleChatComplete}
262
  />
263
  </div>
 
277
  {rightPanel === 'graph'
278
  ? <KnowledgeGraph
279
  highlightedNodes={brainState.highlightedNodes}
280
+ currentUser={currentUser}
281
  refreshTick={graphRefreshTick}
282
  />
283
  : <DreamSequence sparks={brainState.sparks} />
frontend/src/api.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Authenticated fetch wrapper.
3
+ * Reads the JWT from localStorage and attaches it as a Bearer token
4
+ * on every request. Drop-in replacement for fetch().
5
+ */
6
+ export function apiFetch(url, options = {}) {
7
+ const token = localStorage.getItem('soma_token');
8
+ return fetch(url, {
9
+ ...options,
10
+ headers: {
11
+ 'Content-Type': 'application/json',
12
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
13
+ ...options.headers,
14
+ },
15
+ });
16
+ }
frontend/src/components/AuthScreen.css ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .auth-overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background: var(--bg-0);
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ z-index: 1000;
9
+ padding: 24px;
10
+ }
11
+
12
+ .auth-card {
13
+ width: 100%;
14
+ max-width: 380px;
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ gap: 20px;
19
+ animation: soma-rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
20
+ }
21
+
22
+ /* ── Logo ── */
23
+ .auth-logo {
24
+ display: flex;
25
+ align-items: center;
26
+ gap: 14px;
27
+ }
28
+
29
+ .auth-orb {
30
+ width: 36px;
31
+ height: 36px;
32
+ position: relative;
33
+ transform-style: preserve-3d;
34
+ animation: orb-spin 10s linear infinite;
35
+ }
36
+
37
+ @keyframes orb-spin {
38
+ from { transform: perspective(80px) rotateY(0deg) rotateX(15deg); }
39
+ to { transform: perspective(80px) rotateY(360deg) rotateX(15deg); }
40
+ }
41
+
42
+ .auth-orb-ring {
43
+ position: absolute;
44
+ inset: 2px;
45
+ border-radius: 50%;
46
+ border: 1.5px solid var(--pulse);
47
+ opacity: 0.6;
48
+ }
49
+ .auth-orb-ring.r2 { transform: rotateX(60deg); }
50
+ .auth-orb-ring.r3 { transform: rotateX(-60deg); }
51
+
52
+ .auth-orb-core {
53
+ position: absolute;
54
+ inset: 11px;
55
+ background: var(--pulse);
56
+ border-radius: 50%;
57
+ opacity: 0.75;
58
+ filter: blur(1px);
59
+ box-shadow: 0 0 10px var(--pulse);
60
+ }
61
+
62
+ .auth-logo-name {
63
+ font-family: var(--font-mono);
64
+ font-size: 1.4rem;
65
+ font-weight: 700;
66
+ letter-spacing: 0.45em;
67
+ color: var(--text-0);
68
+ }
69
+
70
+ /* ── Headings ── */
71
+ .auth-title {
72
+ font-family: var(--font-display);
73
+ font-style: italic;
74
+ font-size: 1.5rem;
75
+ font-weight: 400;
76
+ color: var(--text-0);
77
+ margin: 0;
78
+ text-align: center;
79
+ }
80
+
81
+ .auth-sub {
82
+ font-size: 0.78rem;
83
+ color: var(--text-2);
84
+ text-align: center;
85
+ line-height: 1.7;
86
+ max-width: 300px;
87
+ margin: -8px 0 0;
88
+ }
89
+
90
+ /* ── Form ── */
91
+ .auth-form {
92
+ width: 100%;
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 14px;
96
+ background: var(--bg-2);
97
+ border: 1px solid var(--border-1);
98
+ border-radius: 16px;
99
+ padding: 24px;
100
+ }
101
+
102
+ .auth-field {
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 6px;
106
+ }
107
+
108
+ .auth-label {
109
+ font-family: var(--font-mono);
110
+ font-size: 0.52rem;
111
+ letter-spacing: 0.18em;
112
+ text-transform: uppercase;
113
+ color: var(--text-2);
114
+ }
115
+
116
+ .auth-input {
117
+ background: var(--bg-1);
118
+ border: 1px solid var(--border-0);
119
+ border-radius: 8px;
120
+ padding: 10px 14px;
121
+ color: var(--text-0);
122
+ font-family: var(--font-body);
123
+ font-size: 0.9rem;
124
+ outline: none;
125
+ transition: border-color 0.2s ease;
126
+ }
127
+
128
+ .auth-input:focus {
129
+ border-color: var(--pulse);
130
+ box-shadow: 0 0 0 2px var(--border-0);
131
+ }
132
+
133
+ .auth-input::placeholder { color: var(--text-2); }
134
+
135
+ /* ── Error ── */
136
+ .auth-error {
137
+ font-size: 0.75rem;
138
+ color: var(--error, #e05252);
139
+ background: rgba(224, 82, 82, 0.08);
140
+ border: 1px solid rgba(224, 82, 82, 0.2);
141
+ border-radius: 6px;
142
+ padding: 8px 12px;
143
+ margin: 0;
144
+ }
145
+
146
+ /* ── Submit ── */
147
+ .auth-submit {
148
+ padding: 11px;
149
+ border-radius: 9px;
150
+ background: var(--pulse);
151
+ color: var(--bg-0);
152
+ border: none;
153
+ font-family: var(--font-mono);
154
+ font-size: 0.7rem;
155
+ font-weight: 700;
156
+ letter-spacing: 0.12em;
157
+ text-transform: uppercase;
158
+ cursor: pointer;
159
+ transition: opacity 0.2s ease;
160
+ margin-top: 4px;
161
+ }
162
+
163
+ .auth-submit:hover:not(:disabled) { opacity: 0.88; }
164
+ .auth-submit:disabled { opacity: 0.4; cursor: not-allowed; }
165
+
166
+ /* ── Toggle ── */
167
+ .auth-toggle {
168
+ font-size: 0.75rem;
169
+ color: var(--text-2);
170
+ margin: 0;
171
+ }
172
+
173
+ .auth-toggle-btn {
174
+ background: none;
175
+ border: none;
176
+ color: var(--pulse);
177
+ font-size: 0.75rem;
178
+ cursor: pointer;
179
+ padding: 0;
180
+ text-decoration: underline;
181
+ text-underline-offset: 2px;
182
+ }
183
+
184
+ .auth-toggle-btn:hover { opacity: 0.8; }
frontend/src/components/AuthScreen.jsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import './AuthScreen.css';
3
+
4
+ function AuthScreen({ onAuth }) {
5
+ const [mode, setMode] = useState('login'); // 'login' | 'register'
6
+ const [username, setUsername] = useState('');
7
+ const [password, setPassword] = useState('');
8
+ const [error, setError] = useState('');
9
+ const [loading, setLoading] = useState(false);
10
+
11
+ const handleSubmit = async (e) => {
12
+ e.preventDefault();
13
+ setError('');
14
+ setLoading(true);
15
+
16
+ try {
17
+ const res = await fetch(`/api/v1/auth/${mode}`, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify({ username: username.trim(), password }),
21
+ });
22
+ const data = await res.json();
23
+
24
+ if (!res.ok) {
25
+ // FastAPI validation errors come back as { detail: [...] }
26
+ const msg = Array.isArray(data.detail)
27
+ ? data.detail[0]?.msg ?? 'Invalid input'
28
+ : data.detail ?? 'Something went wrong';
29
+ setError(msg);
30
+ return;
31
+ }
32
+
33
+ localStorage.setItem('soma_token', data.access_token);
34
+ localStorage.setItem('soma_username', data.username);
35
+ onAuth(data.username);
36
+ } catch {
37
+ setError('Could not reach the server. Is it running?');
38
+ } finally {
39
+ setLoading(false);
40
+ }
41
+ };
42
+
43
+ return (
44
+ <div className="auth-overlay">
45
+ <div className="auth-card">
46
+
47
+ {/* Logo */}
48
+ <div className="auth-logo">
49
+ <div className="auth-orb">
50
+ <div className="auth-orb-ring r1" />
51
+ <div className="auth-orb-ring r2" />
52
+ <div className="auth-orb-ring r3" />
53
+ <div className="auth-orb-core" />
54
+ </div>
55
+ <span className="auth-logo-name">SOMA</span>
56
+ </div>
57
+
58
+ <h1 className="auth-title">
59
+ {mode === 'login' ? 'Welcome back' : 'Create your brain'}
60
+ </h1>
61
+ <p className="auth-sub">
62
+ {mode === 'login'
63
+ ? 'Sign in to access your personal neural space.'
64
+ : 'Your conversations, memories, and knowledge graph — private to you.'}
65
+ </p>
66
+
67
+ <form className="auth-form" onSubmit={handleSubmit}>
68
+ <div className="auth-field">
69
+ <label className="auth-label">Username</label>
70
+ <input
71
+ className="auth-input"
72
+ type="text"
73
+ value={username}
74
+ onChange={e => setUsername(e.target.value)}
75
+ placeholder="e.g. komal"
76
+ autoFocus
77
+ autoComplete="username"
78
+ required
79
+ />
80
+ </div>
81
+
82
+ <div className="auth-field">
83
+ <label className="auth-label">Password</label>
84
+ <input
85
+ className="auth-input"
86
+ type="password"
87
+ value={password}
88
+ onChange={e => setPassword(e.target.value)}
89
+ placeholder="Min. 6 characters"
90
+ autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
91
+ required
92
+ />
93
+ </div>
94
+
95
+ {error && <p className="auth-error">{error}</p>}
96
+
97
+ <button className="auth-submit" type="submit" disabled={loading}>
98
+ {loading ? 'Please wait…' : mode === 'login' ? 'Sign In' : 'Create Account'}
99
+ </button>
100
+ </form>
101
+
102
+ <p className="auth-toggle">
103
+ {mode === 'login' ? "Don't have an account? " : 'Already have an account? '}
104
+ <button
105
+ className="auth-toggle-btn"
106
+ onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); }}
107
+ >
108
+ {mode === 'login' ? 'Sign Up' : 'Sign In'}
109
+ </button>
110
+ </p>
111
+
112
+ </div>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ export default AuthScreen;
requirements.txt CHANGED
@@ -1,5 +1,7 @@
1
  fastapi
2
  uvicorn
 
 
3
  langgraph
4
  langchain
5
  langchain-groq
 
1
  fastapi
2
  uvicorn
3
+ python-jose[cryptography]
4
+ passlib[bcrypt]
5
  langgraph
6
  langchain
7
  langchain-groq