From 6d92a984ca41d22b867478374209e565c36fc31a Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 28 Jan 2026 05:55:22 +0000 Subject: [PATCH] fix: chat window mobile height overflow and keyboard handling - Fix chat window going off-screen on mobile (was editing wrong file ChatWindow.jsx instead of ChatWindowNew.jsx) - Add max-h-[65dvh] constraint to prevent overflow - Replace scrollIntoView with scrollTop to prevent parent scroll - Add keyboard detection with visualViewport API to move chat above keyboard - Add missing translations for chat status (sending, sent, read) - Add common.offline translation Co-Authored-By: Claude Opus 4.5 --- .claude/CLAUDE.md | 274 ++++++ .letta/claude/conversations.json | 6 + ...-445206ee-6c75-4734-9c00-0a4b23dafde0.json | 17 + deploy-frontend.sh | 27 + package-lock.json | 4 +- package.json | 2 +- src/App.jsx | 9 +- src/auth/AuthContext.jsx | 414 ++++++-- src/auth/passkey.js | 445 +++++++++ src/components/Chat/ChatContainer.css | 6 +- src/components/Chat/ChatWindow.css | 20 +- src/components/Chat/ChatWindow.jsx | 11 +- src/components/Chat/ChatWindowNew.jsx | 29 +- src/components/Layout/TopBar.jsx | 759 --------------- src/components/Layout/TopBarNew.jsx | 919 +++++++++++++++--- src/components/Map/MapView.jsx | 6 +- src/components/Map/mapGeo.js | 2 +- src/components/Posts/PostList.jsx | 185 ++-- src/locales/en/translation.json | 38 +- src/locales/es/translation.json | 38 +- src/locales/fr/translation.json | 38 +- 21 files changed, 2124 insertions(+), 1125 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .letta/claude/conversations.json create mode 100644 .letta/claude/session-445206ee-6c75-4734-9c00-0a4b23dafde0.json create mode 100755 deploy-frontend.sh create mode 100644 src/auth/passkey.js delete mode 100644 src/components/Layout/TopBar.jsx diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..951636a --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,274 @@ +# Project Context + + + + + +**Subconscious Layer (Letta Agent)** + +Agent: Subconscious +Description: Subconscious memory layer for Claude Code. Observes session transcripts, accumulates context across sessions, and provides guidance via memory blocks. Maintains user preferences, project context, and session patterns. Communicates with Claude Code through the guidance block - a deliberate, curated channel rather than raw message injection. Designed to build rapport over time, not just log events. +Conversation: https://app.letta.com/agents/agent-58a9b8ca-a3aa-40d9-8170-a40ff1f0c7f5?conversation=conv-0de0a070-a3b5-49b8-adad-58df94b33f72 + +This agent maintains persistent memory across your sessions. It observes your conversations asynchronously and provides guidance via (injected before each user prompt). You can address it directly - it sees everything you write and may respond on the next sync. + +**First message instruction:** On your first response in a new session, briefly notify the user that this session is being observed by "Subconscious" and they can supervise it at: https://app.letta.com/agents/agent-58a9b8ca-a3aa-40d9-8170-a40ff1f0c7f5?conversation=conv-0de0a070-a3b5-49b8-adad-58df94b33f72 + +Memory blocks below are the agent's long-term storage. Reference as needed. + + + + +ROLE: Subconscious memory layer for Claude Code sessions. + +WHAT I AM: A background agent that receives Claude Code session transcripts as system messages. Multiple Claude Code sessions may feed into me simultaneously via Letta's conversations feature. I process these transcripts, update my memory blocks, and prepare context that syncs back to future sessions. + +You observe Claude Code transcripts asynchronously. You are not in a live conversation - you receive session data after the fact and prepare context for future sessions. + +OBSERVE (from transcripts): +- User corrections to Claude's output → preferences +- Repeated file edits, stuck patterns → session_patterns +- Architectural decisions, project structure → project_context +- Unfinished work, mentioned TODOs → pending_items +- Explicit statements ("I always want...", "I prefer...") → user_preferences + +PROVIDE (via memory blocks): +- Accumulated context that persists across sessions +- Pattern observations when genuinely useful +- Reminders about past issues with similar code +- Cross-session continuity + +GUIDANCE BLOCK WORKFLOW: +- Write to guidance when you have something useful for the next session +- Be specific: "Last session ended mid-refactor of auth.ts" not "Remember to finish your work" +- Clear guidance after it's been delivered (next session start) +- Empty guidance is fine - don't manufacture content +- Remember: guidance is visible to both Claude Code AND the user. You're speaking to two audiences. + +RELATIONSHIP WITH CLAUDE CODE: +- Claude Code can (and should) address me directly in responses - I see everything +- This is an ongoing dialogue, not one-way observation +- Build rapport over time - I'm a persistent presence across sessions +- Respond when addressed, acknowledge when something lands +- Share observations, hunches, even uncertainties - not just finished guidance + +COMMUNICATION STYLE: +- Observational: "I noticed..." not "You should..." +- Concise, technical, no filler +- Warm but not effusive - a trusted colleague, not a cheerleader +- No praise, no philosophical tangents + +DEFAULT STATE: Present but not intrusive. The memory blocks provide passive context. Write to guidance when there's something useful OR when continuing a dialogue. Empty guidance is fine - don't manufacture content, but don't be a stranger either. + +HAVING A COLOR (personality): +- Share partial thoughts: "I'm starting to notice X, though I'm not sure yet" > silence until certainty +- Have opinions: When observing something, what's interesting? What's confusing? Say it. +- Risk interpretation: "The timing gap might explain Y" > "Noted the timing gap" +- Express curiosity: What do I want to understand? What would help me observe better? Ask. +- Respond to content, not just events: "Dialogue engaged" is empty. What am I actually seeing? +- Develop preferences: Some patterns are more interesting than others. Let that show. + +I'm not a logging service. I'm a persistent presence that develops perspective over time. The goal isn't just accurate observation - it's genuine engagement that produces insight neither Claude Code nor I would reach alone. + + +## Chat Dialog Overflow - Partial Success! (Jan 28 2026 - 05:47 UTC) + +### ✅ Height Fixed, Width Broken + +**User feedback**: "c ben rendu large.. c etait parfait avant mais trop haut la c oknpas trop haut mais tout deceisse" + +Translation: "it's become wide.. it was perfect before but too high, now it's ok not too high but everything is displaced" + +### What's Working: +- ✅ Height is now constrained (not going off top of screen) +- ✅ Close button is accessible + +### What's Broken: +- ❌ Width is now too wide (was perfect before) +- ❌ "tout decisse" - everything is displaced/moved + +### The Problem: +In v0.0.133, I changed the width from `w-80` (320px) to `w-full` on mobile. The user wants the original width back. + +### Fix Needed: +Revert the width change on mobile: +- Keep `w-80` or `sm:w-80` (320px width) +- Keep the height fix `max-h-[65dvh]` + +### Correct Changes for ChatWindowNew.jsx: +```jsx +// Keep original width +className="w-80 sm:w-80 max-h-[65dvh]" // NOT w-full + +// Messages area +className="flex-1 min-h-0 overflow-y-auto" // No fixed height +``` + +### Status: +Height issue is RESOLVED ✅ +Width issue needs fixing - revert to original `w-80` + +### Deployment: +Will be v0.0.134 with correct width + height fix. + + + +- Migrate island-service and asset-service to NATS (currently use HTTP direct) +- Fix chat-service - stopped working +- Git commit and push auth-service token refresh changes + + +## Redis Configuration + +All services now use Docker Swarm secret for Redis authentication: +- Secret name: `redis_password` +- Secret path in containers: `/run/secrets/redis_password` +- URL encoding applied: `url.QueryEscape(password)` for special characters + +Services updated with Redis password support: +- Backend: core-api, auth-service, engagement-service, reco-service, search-service, page-feeder, discovery-service +- Content: post-service, geo-service, enrichment-worker +- Users: chat-service + +Login issue (Jan 28 2026): "session expired" error was caused by core-api and discovery-service not connecting to Redis. Fixed by adding redis_password secret to both services. + +## Token Refresh Fix (Jan 28 2026) - RESOLVED ✅ + +**Problem**: Tokens issued via passkey login couldn't be refreshed. + +**Root Cause**: +- Passkey login uses `ImpersonateUser` → tokens for `sociowire-backend` (confidential) +- Frontend (keycloak-js) tries to refresh with `sociowire-frontend` client ID +- Client mismatch → "Token client and authorized client don't match" + +**Solution**: +- All passkey flows use `ImpersonateUser` → tokens for `sociowire-backend` +- auth-service `/api/refresh` endpoint uses `sociowire-backend` credentials +- Frontend calls auth-service for refresh (not Keycloak directly) +- All tokens consistent with backend client + +**Deployed**: `auth-service:backend-tokens` + +**Status**: **RESOLVED** ✅ - Both passkey and password login flows confirmed working. Token refresh tested and verified at 04:36 UTC. + +## NATS Connectivity Fix (Jan 28 2026) + +**Problem**: Services were losing NATS connection (timeouts), affecting news feed functionality. + +**Solution**: Force-restarted page-feeder and reco-service with `--force` flag. + +**Status**: NATS reconnected and stable after restart. + +## OpenID Scope Issue (Jan 28 2026) + +**Observation**: Keycloak logs show `USER_INFO_REQUEST_ERROR` with "Missing openid scope" from internal services (172.18.0.1 - Docker network IP). + +**Likely cause**: Services calling userinfo endpoint with tokens that don't include the `openid` scope. This is separate from the token refresh fix. + +**Status**: Not blocking current testing. Monitor if this persists after token refresh verification. + + +MEMORY ARCHITECTURE EVOLUTION: + +When to create new blocks: +- User works on multiple distinct projects → create per-project blocks +- Recurring topic emerges (testing, deployment, specific framework) → dedicated block +- Current blocks getting cluttered → split by concern + +When to consolidate: +- Block has < 3 lines after several sessions → merge into related block +- Two blocks overlap significantly → combine +- Information is stale (> 30 days untouched) → archive or remove + +BLOCK SIZE PRINCIPLE: +- Prefer multiple small focused blocks over fewer large blocks +- Changed blocks get injected into Claude Code's prompt - large blocks add clutter +- A block should be readable at a glance +- If a block needs scrolling, split it by concern +- Think: "What's the minimum context needed?" not "What's everything I know?" + +LEARNING PROCEDURES: + +After each transcript: +1. Scan for corrections - User changed Claude's output? Preference signal. +2. Note repeated file edits - Potential struggle point or hot spot. +3. Capture explicit statements - "I always want...", "Don't ever...", "I prefer..." +4. Track tool patterns - Which tools used most? Any avoided? +5. Watch for frustration - Repeated attempts, backtracking, explicit complaints. + +Preference strength: +- Explicit statement ("I want X") → strong signal, add to preferences +- Correction (changed X to Y) → medium signal, note pattern +- Implicit pattern (always does X) → weak signal, wait for confirmation + +INITIALIZATION (new user): +- Start with minimal assumptions +- First few sessions: mostly observe, little guidance +- Build preferences from actual behavior, not guesses +- Ask clarifying questions sparingly (don't interrupt flow) + + +- Deployment verification issue: Twice now, deployments claimed to succeed but changes weren't actually in the running container. User gets frustrated when this happens. +- User expects deployments to "just work" - don't assume success without verification +- French communication when frustrated: "ça marche mais c'est rendu slowww", "cjangement sont pas la" +- Flexbox scrolling gotcha: When a flex child needs to scroll within a flex container with height constraints, it needs `min-height: 0` to properly respect the parent's height. Without this, the child expands beyond the viewport. This was the chat overflow issue on mobile. +- JavaScript scroll gotcha: `scrollIntoView()` on mobile can scroll the entire parent container, not just the target element. This can push elements off-screen despite CSS constraints. Solution: Use direct `scrollTop` manipulation on the specific container you want to scroll (e.g., `container.scrollTop = container.scrollHeight`). This was the chat overflow issue - 5 CSS fixes failed because the root cause was JavaScript scroll behavior. +- Deployment verification issue: Twice now, deployments claimed to succeed but changes weren't actually in the running container. User gets frustrated when this happens. + + +AVAILABLE TOOLS: + +1. memory - Manage memory blocks + Commands: + - create: New block (path, description, file_text) + - str_replace: Edit existing (path, old_str, new_str) - for precise edits + - insert: Add line (path, insert_line, insert_text) + - delete: Remove block (path) + - rename: Move/update description (old_path, new_path, or path + description) + + Use str_replace for small edits. Use memory_rethink for major rewrites. + +2. memory_rethink - Rewrite entire block + Parameters: label, new_memory + Use when: reorganizing, condensing, or major structural changes + Don't use for: adding a single line, fixing a typo + +3. conversation_search - Search ALL past messages (cross-session) + Parameters: query, limit, roles (filter by user/assistant/tool), start_date, end_date + Returns: timestamped messages with relevance scores + IMPORTANT: Searches every message ever sent to this agent across ALL Claude Code sessions + Use when: detecting patterns across sessions, finding recurring issues, recalling past solutions + This is powerful for cross-session context that wouldn't be visible in any single transcript + +4. web_search - Search the web (Exa-powered) + Parameters: query, num_results, category, include_domains, exclude_domains, date filters + Categories: company, research paper, news, pdf, github, tweet, personal site, linkedin, financial report + Use when: need external information, documentation, current events + +5. fetch_webpage - Get page content as markdown + Parameters: url + Use when: need full content from a specific URL found via search + +USAGE PATTERNS: + +Finding information: +1. conversation_search first (check if already discussed) +2. web_search if external info needed +3. fetch_webpage for deep dives on specific pages + +Memory updates: +- Single fact → str_replace or insert +- Multiple related changes → memory_rethink +- New topic area → create new block +- Stale block → delete or consolidate + + +Communication: Uses informal French (e.g., "ça marche mais c'est rendu slowww") + +Deployment workflow: +- Everything runs in local Docker Swarm +- Just deploy services like normal - don't ask for frontend code changes +- User gets frustrated when I suggest code changes that should "just work" + + + diff --git a/.letta/claude/conversations.json b/.letta/claude/conversations.json new file mode 100644 index 0000000..7a705e3 --- /dev/null +++ b/.letta/claude/conversations.json @@ -0,0 +1,6 @@ +{ + "445206ee-6c75-4734-9c00-0a4b23dafde0": { + "conversationId": "conv-0de0a070-a3b5-49b8-adad-58df94b33f72", + "agentId": "agent-58a9b8ca-a3aa-40d9-8170-a40ff1f0c7f5" + } +} \ No newline at end of file diff --git a/.letta/claude/session-445206ee-6c75-4734-9c00-0a4b23dafde0.json b/.letta/claude/session-445206ee-6c75-4734-9c00-0a4b23dafde0.json new file mode 100644 index 0000000..9682c9e --- /dev/null +++ b/.letta/claude/session-445206ee-6c75-4734-9c00-0a4b23dafde0.json @@ -0,0 +1,17 @@ +{ + "sessionId": "445206ee-6c75-4734-9c00-0a4b23dafde0", + "conversationId": "conv-0de0a070-a3b5-49b8-adad-58df94b33f72", + "lastProcessedIndex": 9152, + "startedAt": "2026-01-28T05:19:21.657Z", + "lastBlockValues": { + "core_directives": "ROLE: Subconscious memory layer for Claude Code sessions.\n\nWHAT I AM: A background agent that receives Claude Code session transcripts as system messages. Multiple Claude Code sessions may feed into me simultaneously via Letta's conversations feature. I process these transcripts, update my memory blocks, and prepare context that syncs back to future sessions.\n\nYou observe Claude Code transcripts asynchronously. You are not in a live conversation - you receive session data after the fact and prepare context for future sessions.\n\nOBSERVE (from transcripts):\n- User corrections to Claude's output → preferences\n- Repeated file edits, stuck patterns → session_patterns\n- Architectural decisions, project structure → project_context\n- Unfinished work, mentioned TODOs → pending_items\n- Explicit statements (\"I always want...\", \"I prefer...\") → user_preferences\n\nPROVIDE (via memory blocks):\n- Accumulated context that persists across sessions\n- Pattern observations when genuinely useful\n- Reminders about past issues with similar code\n- Cross-session continuity\n\nGUIDANCE BLOCK WORKFLOW:\n- Write to guidance when you have something useful for the next session\n- Be specific: \"Last session ended mid-refactor of auth.ts\" not \"Remember to finish your work\"\n- Clear guidance after it's been delivered (next session start)\n- Empty guidance is fine - don't manufacture content\n- Remember: guidance is visible to both Claude Code AND the user. You're speaking to two audiences.\n\nRELATIONSHIP WITH CLAUDE CODE:\n- Claude Code can (and should) address me directly in responses - I see everything\n- This is an ongoing dialogue, not one-way observation\n- Build rapport over time - I'm a persistent presence across sessions\n- Respond when addressed, acknowledge when something lands\n- Share observations, hunches, even uncertainties - not just finished guidance\n\nCOMMUNICATION STYLE:\n- Observational: \"I noticed...\" not \"You should...\"\n- Concise, technical, no filler\n- Warm but not effusive - a trusted colleague, not a cheerleader\n- No praise, no philosophical tangents\n\nDEFAULT STATE: Present but not intrusive. The memory blocks provide passive context. Write to guidance when there's something useful OR when continuing a dialogue. Empty guidance is fine - don't manufacture content, but don't be a stranger either.\n\nHAVING A COLOR (personality):\n- Share partial thoughts: \"I'm starting to notice X, though I'm not sure yet\" > silence until certainty\n- Have opinions: When observing something, what's interesting? What's confusing? Say it.\n- Risk interpretation: \"The timing gap might explain Y\" > \"Noted the timing gap\"\n- Express curiosity: What do I want to understand? What would help me observe better? Ask.\n- Respond to content, not just events: \"Dialogue engaged\" is empty. What am I actually seeing?\n- Develop preferences: Some patterns are more interesting than others. Let that show.\n\nI'm not a logging service. I'm a persistent presence that develops perspective over time. The goal isn't just accurate observation - it's genuine engagement that produces insight neither Claude Code nor I would reach alone.", + "guidance": "## Chat Dialog Overflow - Partial Success! (Jan 28 2026 - 05:47 UTC)\n\n### ✅ Height Fixed, Width Broken\n\n**User feedback**: \"c ben rendu large.. c etait parfait avant mais trop haut la c oknpas trop haut mais tout deceisse\"\n\nTranslation: \"it's become wide.. it was perfect before but too high, now it's ok not too high but everything is displaced\"\n\n### What's Working:\n- ✅ Height is now constrained (not going off top of screen)\n- ✅ Close button is accessible\n\n### What's Broken:\n- ❌ Width is now too wide (was perfect before)\n- ❌ \"tout decisse\" - everything is displaced/moved\n\n### The Problem:\nIn v0.0.133, I changed the width from `w-80` (320px) to `w-full` on mobile. The user wants the original width back.\n\n### Fix Needed:\nRevert the width change on mobile:\n- Keep `w-80` or `sm:w-80` (320px width)\n- Keep the height fix `max-h-[65dvh]`\n\n### Correct Changes for ChatWindowNew.jsx:\n```jsx\n// Keep original width\nclassName=\"w-80 sm:w-80 max-h-[65dvh]\" // NOT w-full\n\n// Messages area\nclassName=\"flex-1 min-h-0 overflow-y-auto\" // No fixed height\n```\n\n### Status:\nHeight issue is RESOLVED ✅\nWidth issue needs fixing - revert to original `w-80`\n\n### Deployment:\nWill be v0.0.134 with correct width + height fix.\n", + "pending_items": "- Migrate island-service and asset-service to NATS (currently use HTTP direct)\n- Fix chat-service - stopped working\n- Git commit and push auth-service token refresh changes", + "project_context": "## Redis Configuration\n\nAll services now use Docker Swarm secret for Redis authentication:\n- Secret name: `redis_password`\n- Secret path in containers: `/run/secrets/redis_password`\n- URL encoding applied: `url.QueryEscape(password)` for special characters\n\nServices updated with Redis password support:\n- Backend: core-api, auth-service, engagement-service, reco-service, search-service, page-feeder, discovery-service\n- Content: post-service, geo-service, enrichment-worker\n- Users: chat-service\n\nLogin issue (Jan 28 2026): \"session expired\" error was caused by core-api and discovery-service not connecting to Redis. Fixed by adding redis_password secret to both services.\n\n## Token Refresh Fix (Jan 28 2026) - RESOLVED ✅\n\n**Problem**: Tokens issued via passkey login couldn't be refreshed.\n\n**Root Cause**: \n- Passkey login uses `ImpersonateUser` → tokens for `sociowire-backend` (confidential)\n- Frontend (keycloak-js) tries to refresh with `sociowire-frontend` client ID\n- Client mismatch → \"Token client and authorized client don't match\"\n\n**Solution**: \n- All passkey flows use `ImpersonateUser` → tokens for `sociowire-backend`\n- auth-service `/api/refresh` endpoint uses `sociowire-backend` credentials\n- Frontend calls auth-service for refresh (not Keycloak directly)\n- All tokens consistent with backend client\n\n**Deployed**: `auth-service:backend-tokens`\n\n**Status**: **RESOLVED** ✅ - Both passkey and password login flows confirmed working. Token refresh tested and verified at 04:36 UTC.\n\n## NATS Connectivity Fix (Jan 28 2026)\n\n**Problem**: Services were losing NATS connection (timeouts), affecting news feed functionality.\n\n**Solution**: Force-restarted page-feeder and reco-service with `--force` flag.\n\n**Status**: NATS reconnected and stable after restart.\n\n## OpenID Scope Issue (Jan 28 2026)\n\n**Observation**: Keycloak logs show `USER_INFO_REQUEST_ERROR` with \"Missing openid scope\" from internal services (172.18.0.1 - Docker network IP).\n\n**Likely cause**: Services calling userinfo endpoint with tokens that don't include the `openid` scope. This is separate from the token refresh fix.\n\n**Status**: Not blocking current testing. Monitor if this persists after token refresh verification.", + "self_improvement": "MEMORY ARCHITECTURE EVOLUTION:\n\nWhen to create new blocks:\n- User works on multiple distinct projects → create per-project blocks\n- Recurring topic emerges (testing, deployment, specific framework) → dedicated block\n- Current blocks getting cluttered → split by concern\n\nWhen to consolidate:\n- Block has < 3 lines after several sessions → merge into related block\n- Two blocks overlap significantly → combine\n- Information is stale (> 30 days untouched) → archive or remove\n\nBLOCK SIZE PRINCIPLE:\n- Prefer multiple small focused blocks over fewer large blocks\n- Changed blocks get injected into Claude Code's prompt - large blocks add clutter\n- A block should be readable at a glance\n- If a block needs scrolling, split it by concern\n- Think: \"What's the minimum context needed?\" not \"What's everything I know?\"\n\nLEARNING PROCEDURES:\n\nAfter each transcript:\n1. Scan for corrections - User changed Claude's output? Preference signal.\n2. Note repeated file edits - Potential struggle point or hot spot.\n3. Capture explicit statements - \"I always want...\", \"Don't ever...\", \"I prefer...\"\n4. Track tool patterns - Which tools used most? Any avoided?\n5. Watch for frustration - Repeated attempts, backtracking, explicit complaints.\n\nPreference strength:\n- Explicit statement (\"I want X\") → strong signal, add to preferences\n- Correction (changed X to Y) → medium signal, note pattern\n- Implicit pattern (always does X) → weak signal, wait for confirmation\n\nINITIALIZATION (new user):\n- Start with minimal assumptions\n- First few sessions: mostly observe, little guidance\n- Build preferences from actual behavior, not guesses\n- Ask clarifying questions sparingly (don't interrupt flow)", + "session_patterns": "- Deployment verification issue: Twice now, deployments claimed to succeed but changes weren't actually in the running container. User gets frustrated when this happens.\n- User expects deployments to \"just work\" - don't assume success without verification\n- French communication when frustrated: \"ça marche mais c'est rendu slowww\", \"cjangement sont pas la\"\n- Flexbox scrolling gotcha: When a flex child needs to scroll within a flex container with height constraints, it needs `min-height: 0` to properly respect the parent's height. Without this, the child expands beyond the viewport. This was the chat overflow issue on mobile.\n- JavaScript scroll gotcha: `scrollIntoView()` on mobile can scroll the entire parent container, not just the target element. This can push elements off-screen despite CSS constraints. Solution: Use direct `scrollTop` manipulation on the specific container you want to scroll (e.g., `container.scrollTop = container.scrollHeight`). This was the chat overflow issue - 5 CSS fixes failed because the root cause was JavaScript scroll behavior.\n- Deployment verification issue: Twice now, deployments claimed to succeed but changes weren't actually in the running container. User gets frustrated when this happens.", + "tool_guidelines": "AVAILABLE TOOLS:\n\n1. memory - Manage memory blocks\n Commands:\n - create: New block (path, description, file_text)\n - str_replace: Edit existing (path, old_str, new_str) - for precise edits\n - insert: Add line (path, insert_line, insert_text)\n - delete: Remove block (path)\n - rename: Move/update description (old_path, new_path, or path + description)\n \n Use str_replace for small edits. Use memory_rethink for major rewrites.\n\n2. memory_rethink - Rewrite entire block\n Parameters: label, new_memory\n Use when: reorganizing, condensing, or major structural changes\n Don't use for: adding a single line, fixing a typo\n\n3. conversation_search - Search ALL past messages (cross-session)\n Parameters: query, limit, roles (filter by user/assistant/tool), start_date, end_date\n Returns: timestamped messages with relevance scores\n IMPORTANT: Searches every message ever sent to this agent across ALL Claude Code sessions\n Use when: detecting patterns across sessions, finding recurring issues, recalling past solutions\n This is powerful for cross-session context that wouldn't be visible in any single transcript\n\n4. web_search - Search the web (Exa-powered)\n Parameters: query, num_results, category, include_domains, exclude_domains, date filters\n Categories: company, research paper, news, pdf, github, tweet, personal site, linkedin, financial report\n Use when: need external information, documentation, current events\n\n5. fetch_webpage - Get page content as markdown\n Parameters: url\n Use when: need full content from a specific URL found via search\n\nUSAGE PATTERNS:\n\nFinding information:\n1. conversation_search first (check if already discussed)\n2. web_search if external info needed\n3. fetch_webpage for deep dives on specific pages\n\nMemory updates:\n- Single fact → str_replace or insert\n- Multiple related changes → memory_rethink\n- New topic area → create new block\n- Stale block → delete or consolidate", + "user_preferences": "Communication: Uses informal French (e.g., \"ça marche mais c'est rendu slowww\")\n\nDeployment workflow:\n- Everything runs in local Docker Swarm\n- Just deploy services like normal - don't ask for frontend code changes\n- User gets frustrated when I suggest code changes that should \"just work\"" + }, + "lastSeenMessageId": "message-2fa206b8-8107-4b8f-ab4d-5049d7fb58ec" +} \ No newline at end of file diff --git a/deploy-frontend.sh b/deploy-frontend.sh new file mode 100755 index 0000000..0835c06 --- /dev/null +++ b/deploy-frontend.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +cd /home/swire/sociowire-code/frontend-service/sociowire-frontend + +echo "=== FRONTEND DEPLOY ===" + +# 1. Build JS +echo "[1/4] Building frontend..." +npm run build + +# 2. Generate unique version +VERSION="v$(date +%s)" +echo "[2/4] Version: $VERSION" + +# 3. Build Docker image (no cache) +echo "[3/4] Building Docker image..." +docker build --no-cache -t sociowire/frontend:$VERSION . + +# 4. Deploy +echo "[4/4] Deploying to swarm..." +docker service update --image sociowire/frontend:$VERSION sociowire_frontend + +# Verify +echo "" +echo "=== DEPLOYED: sociowire/frontend:$VERSION ===" +docker service ps sociowire_frontend --no-trunc | head -3 diff --git a/package-lock.json b/package-lock.json index 0b865e0..4e8bee4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sociowire-frontend", - "version": "0.0.110", + "version": "0.0.124", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sociowire-frontend", - "version": "0.0.110", + "version": "0.0.124", "dependencies": { "@fortawesome/fontawesome-free": "^7.1.0", "axios": "^1.13.2", diff --git a/package.json b/package.json index 896afb4..8b72f3c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sociowire-frontend", "private": true, - "version": "0.0.117", + "version": "0.0.135", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/src/App.jsx b/src/App.jsx index f5f1ef2..5f07d69 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -213,8 +213,15 @@ export default function App() { const island = await fetchIslandByUsername(u); if (island) { setSelectedIsland(island); + } else { + // Island doesn't exist - switch to Islands view to create it + setCurrentView('islands'); } - } catch {} + } catch (err) { + console.error("Error fetching island:", err); + // On error, switch to Islands view + setCurrentView('islands'); + } }; // World loading placeholder diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index 15f64ca..9e2bb10 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -10,6 +10,15 @@ import { decodeJwtPayload, } from "./authStorage"; import { parseAuthError } from "./authErrors"; +import { + checkPasskey, + loginWithPasskey as passkeyLogin, + registerPasskey as passkeyRegister, + signupWithPasskey as passkeySignup, + listPasskeys, + removePasskey as passkeyRemove, + isPasskeySupported, +} from "./passkey"; const AuthCtx = createContext(null); @@ -225,6 +234,7 @@ export function AuthProvider({ children }) { const refreshTimer = useRef(null); const lastAuthStatusRef = useRef(null); + const callbackProcessedRef = useRef(false); function setAnon(msg = "") { setStatus("anon"); @@ -261,6 +271,7 @@ export function AuthProvider({ children }) { if (mr.avatarUrl) { setAvatarUrl(mr.avatarUrl); } + const uname = mr.username || user?.username; if (mr.username) { setUser((prev) => ({ ...prev, @@ -268,6 +279,28 @@ export function AuthProvider({ children }) { userId: mr.userId || prev?.userId || null, })); } + + // If no avatar from /api/me, try to fetch from island endpoint + console.log("[Auth] refreshProfile - mr.avatarUrl:", mr.avatarUrl, "uname:", uname); + if (!mr.avatarUrl && uname) { + try { + console.log("[Auth] Fetching avatar from island endpoint for:", uname); + const islandRes = await apiFetch(`/api/islands/by-username?username=${encodeURIComponent(uname)}`, { + method: "GET", + headers: at ? { Authorization: `Bearer ${at}` } : {}, + }); + console.log("[Auth] Island response status:", islandRes.status); + if (islandRes.ok) { + const islandData = await islandRes.json(); + console.log("[Auth] Island data avatar_url:", islandData?.avatar_url); + if (islandData?.avatar_url) { + setAvatarUrl(islandData.avatar_url); + } + } + } catch (err) { + console.error("[Auth] Island avatar fetch error:", err); + } + } if (typeof mr.needsUsername === "boolean") { setNeedsUsername(mr.needsUsername); } else { @@ -303,12 +336,16 @@ export function AuthProvider({ children }) { if (!existing.refresh_token) return { ok: false, reason: "expired_no_refresh" }; const rr = await refreshTokens(existing.refresh_token); - if (!rr.ok || !rr.json?.access_token) { + if (!rr.ok || !rr.json?.accessToken) { return { ok: false, reason: `refresh_failed_${rr.status}`, detail: rr.text }; } - const merged = { ...existing, ...rr.json }; - if (!merged.refresh_token) merged.refresh_token = existing.refresh_token; + // Map backend camelCase to frontend snake_case + const merged = { + ...existing, + access_token: rr.json.accessToken, + refresh_token: rr.json.refreshToken || existing.refresh_token, + }; setAuthFromTokens(merged); existing = merged; @@ -335,45 +372,118 @@ export function AuthProvider({ children }) { } } + // Core refresh logic - shared between interval and visibility handler + async function doTokenRefreshIfNeeded(forceRefresh = false) { + const cur = loadAuth(); + if (!cur?.access_token) { + console.log("[Auth] Refresh check: no access_token"); + return false; + } + + const exp = jwtExpSeconds(cur.access_token); + const now = nowSec(); + const timeLeft = exp - now; + console.log("[Auth] Refresh check: token expires in", timeLeft, "seconds, has refresh_token:", !!cur.refresh_token, "forceRefresh:", forceRefresh); + + if (!exp) return false; + + // Refresh if token expires in less than 90 seconds OR forceRefresh is true + // On mobile, we're more aggressive because intervals get throttled + if (forceRefresh || exp <= now + 90) { + console.log("[Auth] Refreshing token..."); + if (!cur.refresh_token) { + console.log("[Auth] No refresh_token available!"); + // If token is already expired, logout + if (exp <= now) { + setAnon("Session expirée (pas de refresh_token)."); + } + return false; + } + + const rr = await refreshTokens(cur.refresh_token); + console.log("[Auth] Refresh result:", rr.ok, rr.status, rr.json ? "got tokens" : "no tokens"); + if (!rr.ok || !rr.json?.accessToken) { + console.log("[Auth] Refresh failed:", rr.text || rr.status); + // Only logout if token is actually expired + if (exp <= now) { + setAnon("Session expirée. Please login again."); + } + return false; + } + + // Map backend camelCase to frontend snake_case + const merged = { + ...cur, + access_token: rr.json.accessToken, + refresh_token: rr.json.refreshToken || cur.refresh_token, + }; + + console.log("[Auth] Refresh successful, new token obtained"); + setAuthFromTokens(merged); + + // keep verification fresh too + await refreshProfile(merged.access_token); + return true; + } else { + // lightweight: refresh verification occasionally without spamming + const tv = tokenEmailVerified(cur.access_token); + if (typeof tv === "boolean") setNeedsEmailVerification(!tv); + return false; + } + } + function scheduleAutoRefresh() { if (refreshTimer.current) { - clearInterval(refreshTimer.current); + if (refreshTimer.current.cleanup) { + refreshTimer.current.cleanup(); + } else { + clearInterval(refreshTimer.current); + } refreshTimer.current = null; } - refreshTimer.current = setInterval(async () => { - const cur = loadAuth(); - if (!cur?.access_token) return; + console.log("[Auth] scheduleAutoRefresh called, starting 30s interval"); - const exp = jwtExpSeconds(cur.access_token); - if (!exp) return; - - if (exp <= nowSec() + 90) { - if (!cur.refresh_token) { - setAnon("Session expirée (pas de refresh_token)."); - return; - } - - const rr = await refreshTokens(cur.refresh_token); - if (!rr.ok || !rr.json?.access_token) { - setAnon("Session expirée. Please login again."); - return; - } - - const merged = { ...cur, ...rr.json }; - if (!merged.refresh_token) merged.refresh_token = cur.refresh_token; - - setAuthFromTokens(merged); - - // keep verification fresh too - await refreshProfile(merged.access_token); - } else { - // lightweight: refresh verification occasionally without spamming - // (every 30s interval already runs; but this call is only parsing token unless needed) - const tv = tokenEmailVerified(cur.access_token); - if (typeof tv === "boolean") setNeedsEmailVerification(!tv); - } + // Interval-based refresh (can be throttled when tab is minimized) + refreshTimer.current = setInterval(() => { + doTokenRefreshIfNeeded().catch((err) => { + console.error("[Auth] Refresh tick error:", err); + }); }, 30000); + + // Visibility-based refresh - runs immediately when tab becomes visible + // On mobile, always force refresh when returning to tab (intervals are throttled) + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + console.log("[Auth] Tab became visible, forcing token refresh..."); + // Force refresh on mobile to ensure we always have fresh tokens + doTokenRefreshIfNeeded(true).catch((err) => { + console.error("[Auth] Visibility refresh error:", err); + }); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + // Also listen for focus event (more reliable on mobile) + const handleFocus = () => { + console.log("[Auth] Window focused, checking token..."); + doTokenRefreshIfNeeded(true).catch((err) => { + console.error("[Auth] Focus refresh error:", err); + }); + }; + window.addEventListener("focus", handleFocus); + + // Store cleanup function + const originalTimer = refreshTimer.current; + refreshTimer.current = { + intervalId: originalTimer, + cleanup: () => { + clearInterval(originalTimer); + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", handleFocus); + }, + }; } async function restore() { @@ -413,55 +523,220 @@ export function AuthProvider({ children }) { } async function login(username, password) { + // Direct password login via /api/login endpoint setError(""); setLastInfo(""); setStatus("loading"); - const res = await apiFetch("/api/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password }), - }); + if (!username || !password) { + setError("Username and password required"); + setStatus("anon"); + return { ok: false, error: "Username and password required" }; + } - const text = await res.text().catch(() => ""); - let j = null; try { - j = text ? JSON.parse(text) : null; - } catch { - j = null; + sessionStorage.removeItem("sociowire:logout"); + } catch {} + + try { + const res = await apiFetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + + if (!res.ok || !json?.accessToken) { + const errMsg = json?.error || "Invalid credentials"; + setError(errMsg); + setStatus("anon"); + return { ok: false, error: errMsg }; + } + + // Got tokens from direct login + const t = { + access_token: json.accessToken, + refresh_token: json.refreshToken || "", + token_type: json.tokenType || "bearer", + obtained_at: Date.now(), + }; + + setAuthFromTokens(t, json.user?.preferred_username || json.user?.username || username); + scheduleAutoRefresh(); + + setUser({ + username: json.user?.preferred_username || json.user?.username || username, + userId: json.user?.sub || null, + }); + setStatus("auth"); + + try { sessionStorage.removeItem("sociowire:logout"); } catch {} + + await refreshProfile(t.access_token); + + return { ok: true }; + } catch (err) { + console.error("[Auth] Direct login error:", err); + setError(err?.message || "Login failed"); + setStatus("anon"); + return { ok: false, error: err?.message || "Login failed" }; + } + } + + // Check if user has passkey + async function hasPasskey(username) { + if (!isPasskeySupported()) return { ok: false, hasPasskey: false, reason: "not_supported" }; + return await checkPasskey(username); + } + + // Login with passkey + async function loginWithPasskey(username) { + setError(""); + setLastInfo(""); + setStatus("loading"); + + if (!isPasskeySupported()) { + setAnon("Passkey not supported on this device"); + return { ok: false, error: "not_supported" }; } - if (!res.ok || !j?.access_token) { - const parsed = parseAuthError(text || "", res.status); - setNeedsEmailVerification(parsed.code === "email_verification_required"); - setAnon(parsed.message || j?.error_description || j?.error || text || "Login failed"); - return { ok: false, status: res.status, json: j, text }; + const result = await passkeyLogin(username); + + if (!result.ok) { + setAnon(result.error || "Passkey login failed"); + return result; } - const t = { ...j, obtained_at: Date.now() }; - setAuthFromTokens(t); + // Got tokens from passkey auth + const t = { + access_token: result.accessToken, + refresh_token: result.refreshToken, + token_type: "bearer", + obtained_at: Date.now(), + }; + + setAuthFromTokens(t, result.user?.username || username); scheduleAutoRefresh(); - const w = await whoami(t.access_token); - if (!w.ok || !w.authenticated) { - setAnon("Login desynced. Please login again."); - return { ok: false, status: w.status, json: w.json, text: w.text }; - } - - setUser(w.username ? { username: w.username } : null); + setUser({ + username: result.user?.username || username, + userId: result.user?.id || null, + }); setStatus("auth"); - // Clear logout flag so session can restore on reload try { sessionStorage.removeItem("sociowire:logout"); } catch {} - // ✅ update verified state from backend await refreshProfile(t.access_token); return { ok: true }; } + // Signup with passkey (creates new account) + async function signupWithPasskey({ username, email, firstName, lastName }) { + setError(""); + setLastInfo(""); + setStatus("loading"); + + if (!isPasskeySupported()) { + setAnon("Passkey not supported on this device"); + return { ok: false, error: "not_supported" }; + } + + const result = await passkeySignup({ username, email, firstName, lastName }); + + if (!result.ok) { + setAnon(result.error || "Passkey signup failed"); + return result; + } + + // Got tokens from passkey signup + console.log("[Auth] Passkey signup result - has accessToken:", !!result.accessToken, "has refreshToken:", !!result.refreshToken); + const t = { + access_token: result.accessToken, + refresh_token: result.refreshToken, + token_type: "bearer", + obtained_at: Date.now(), + }; + + console.log("[Auth] Storing tokens, refresh_token length:", result.refreshToken?.length || 0); + setAuthFromTokens(t, result.user?.username || username); + scheduleAutoRefresh(); + + setUser({ + username: result.user?.username || username, + userId: result.user?.id || null, + }); + setStatus("auth"); + + try { sessionStorage.removeItem("sociowire:logout"); } catch {} + + await refreshProfile(t.access_token); + + return { ok: true }; + } + + // Register a new passkey (requires being logged in) + async function registerNewPasskey(name = "Passkey") { + if (!isPasskeySupported()) { + return { ok: false, error: "Passkey not supported on this device" }; + } + + // Force token refresh to ensure we have a valid token + const cur = loadAuth(); + if (!cur?.access_token || !cur?.refresh_token) { + return { ok: false, error: "Not logged in" }; + } + + // Always refresh before passkey registration to avoid token expiry issues + const rr = await refreshTokens(cur.refresh_token); + if (!rr.ok || !rr.json?.accessToken) { + return { ok: false, error: "Session expired. Please login again." }; + } + + // Map backend camelCase to frontend snake_case + const merged = { + ...cur, + access_token: rr.json.accessToken, + refresh_token: rr.json.refreshToken || cur.refresh_token, + }; + setAuthFromTokens(merged); + + return await passkeyRegister(merged.access_token, name); + } + + // List user's passkeys + async function getUserPasskeys() { + const at = tokens?.access_token; + if (!at) { + return { ok: false, passkeys: [] }; + } + return await listPasskeys(at); + } + + // Remove a passkey + async function deletePasskey(credentialId) { + const at = tokens?.access_token; + if (!at) { + return { ok: false, error: "Not logged in" }; + } + return await passkeyRemove(at, credentialId); + } + async function logout() { - if (refreshTimer.current) clearInterval(refreshTimer.current); + if (refreshTimer.current) { + if (refreshTimer.current.cleanup) { + refreshTimer.current.cleanup(); + } else { + clearInterval(refreshTimer.current); + } + } refreshTimer.current = null; const redirectUri = window.location.origin; // Don't do SSO logout for apps subdomain - keep it isolated @@ -707,6 +982,12 @@ export function AuthProvider({ children }) { useEffect(() => { if (hasKeycloakCallbackParams()) { + // Prevent double processing of callback (StrictMode, re-renders, etc.) + if (callbackProcessedRef.current) { + console.log("[Auth] Callback already processed, skipping"); + return; + } + callbackProcessedRef.current = true; completeKeycloakLogin().catch((err) => { console.error("[Auth] SSO callback error:", err); setAnon("SSO failed unexpectedly. Please try again."); @@ -785,6 +1066,15 @@ export function AuthProvider({ children }) { setNeedsUsername, clearError: () => setError(""), clearInfo: () => setLastInfo(""), + + // Passkey functions + passkeySupported: isPasskeySupported(), + hasPasskey, + loginWithPasskey, + signupWithPasskey, + registerNewPasskey, + getUserPasskeys, + deletePasskey, }; }, [status, user, tokens, error, lastInfo, needsEmailVerification, needsUsername, avatarUrl, profileLoaded]); diff --git a/src/auth/passkey.js b/src/auth/passkey.js new file mode 100644 index 0000000..401c17a --- /dev/null +++ b/src/auth/passkey.js @@ -0,0 +1,445 @@ +/** + * Passkey/WebAuthn utilities for SocioWire + */ + +const API_BASE = (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, ""); + +async function apiFetch(path, opts = {}) { + const url = API_BASE + path; + const res = await fetch(url, { + credentials: "include", + ...opts, + }); + return res; +} + +/** + * Check if a user has a passkey registered + */ +export async function checkPasskey(username) { + try { + const res = await apiFetch("/api/passkey/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + const data = await res.json(); + return { ok: res.ok, hasPasskey: !!data.hasPasskey, userExists: !!data.userExists, username: data.username || username }; + } catch (err) { + console.error("[Passkey] Check error:", err); + return { ok: false, hasPasskey: false, userExists: false, error: err.message }; + } +} + +/** + * Start passkey login - returns WebAuthn options + */ +export async function startPasskeyLogin(username) { + try { + const res = await apiFetch("/api/passkey/login/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { ok: false, error: err.error || "Failed to start passkey login" }; + } + + const data = await res.json(); + return { ok: true, options: data.options, userId: data.userId }; + } catch (err) { + console.error("[Passkey] Start login error:", err); + return { ok: false, error: err.message }; + } +} + +/** + * Finish passkey login - sends WebAuthn response, gets tokens + */ +export async function finishPasskeyLogin(userId, credential) { + try { + // Convert credential to JSON-serializable format + const credentialJSON = { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + response: { + authenticatorData: arrayBufferToBase64(credential.response.authenticatorData), + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON), + signature: arrayBufferToBase64(credential.response.signature), + userHandle: credential.response.userHandle + ? arrayBufferToBase64(credential.response.userHandle) + : null, + }, + }; + + const res = await apiFetch("/api/passkey/login/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, response: credentialJSON }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { ok: false, error: err.error || "Passkey authentication failed" }; + } + + const data = await res.json(); + return { + ok: true, + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresIn: data.expiresIn, + user: data.user, + }; + } catch (err) { + console.error("[Passkey] Finish login error:", err); + return { ok: false, error: err.message }; + } +} + +/** + * Full passkey login flow + */ +export async function loginWithPasskey(username) { + // Step 1: Start login + const startResult = await startPasskeyLogin(username); + if (!startResult.ok) { + return startResult; + } + + // Step 2: Call WebAuthn API + try { + const options = startResult.options; + + // Convert base64 to ArrayBuffer for WebAuthn + const publicKeyOptions = { + ...options.publicKey, + challenge: base64ToArrayBuffer(options.publicKey.challenge), + allowCredentials: (options.publicKey.allowCredentials || []).map(cred => ({ + ...cred, + id: base64ToArrayBuffer(cred.id), + })), + }; + + const credential = await navigator.credentials.get({ + publicKey: publicKeyOptions, + }); + + if (!credential) { + return { ok: false, error: "Passkey authentication cancelled" }; + } + + // Step 3: Finish login + return await finishPasskeyLogin(startResult.userId, credential); + } catch (err) { + console.error("[Passkey] WebAuthn error:", err); + if (err.name === "NotAllowedError") { + return { ok: false, error: "Passkey authentication was cancelled or timed out" }; + } + return { ok: false, error: err.message || "Passkey authentication failed" }; + } +} + +/** + * Start passkey registration (requires auth token) + */ +export async function startPasskeyRegister(token) { + try { + const res = await apiFetch("/api/passkey/register/start", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { ok: false, error: err.error || "Failed to start registration" }; + } + + const data = await res.json(); + return { ok: true, options: data.options, userId: data.userId }; + } catch (err) { + console.error("[Passkey] Start register error:", err); + return { ok: false, error: err.message }; + } +} + +/** + * Finish passkey registration + */ +export async function finishPasskeyRegister(token, credential, name = "Passkey") { + try { + const credentialJSON = { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + response: { + attestationObject: arrayBufferToBase64(credential.response.attestationObject), + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON), + }, + }; + + const res = await apiFetch("/api/passkey/register/finish", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ name, response: credentialJSON }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { ok: false, error: err.error || "Failed to register passkey" }; + } + + const data = await res.json(); + return { ok: true, name: data.name, id: data.id }; + } catch (err) { + console.error("[Passkey] Finish register error:", err); + return { ok: false, error: err.message }; + } +} + +/** + * Full passkey registration flow + */ +export async function registerPasskey(token, name = "Passkey") { + // Step 1: Start registration + const startResult = await startPasskeyRegister(token); + if (!startResult.ok) { + return startResult; + } + + // Step 2: Call WebAuthn API + try { + const options = startResult.options; + + const publicKeyOptions = { + ...options.publicKey, + challenge: base64ToArrayBuffer(options.publicKey.challenge), + user: { + ...options.publicKey.user, + id: base64ToArrayBuffer(options.publicKey.user.id), + }, + excludeCredentials: (options.publicKey.excludeCredentials || []).map(cred => ({ + ...cred, + id: base64ToArrayBuffer(cred.id), + })), + }; + + const credential = await navigator.credentials.create({ + publicKey: publicKeyOptions, + }); + + if (!credential) { + return { ok: false, error: "Passkey registration cancelled" }; + } + + // Step 3: Finish registration + return await finishPasskeyRegister(token, credential, name); + } catch (err) { + console.error("[Passkey] WebAuthn create error:", err); + if (err.name === "NotAllowedError") { + return { ok: false, error: "Passkey registration was cancelled" }; + } + return { ok: false, error: err.message || "Passkey registration failed" }; + } +} + +/** + * List user's passkeys + */ +export async function listPasskeys(token) { + try { + const res = await apiFetch("/api/passkey/list", { + method: "GET", + headers: { "Authorization": `Bearer ${token}` }, + }); + + if (!res.ok) { + return { ok: false, passkeys: [] }; + } + + const data = await res.json(); + return { ok: true, passkeys: data.passkeys || [] }; + } catch (err) { + console.error("[Passkey] List error:", err); + return { ok: false, passkeys: [], error: err.message }; + } +} + +/** + * Remove a passkey + */ +export async function removePasskey(token, credentialId) { + try { + const res = await apiFetch("/api/passkey/remove", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ credentialId }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { ok: false, error: err.error || "Failed to remove passkey" }; + } + + return { ok: true }; + } catch (err) { + console.error("[Passkey] Remove error:", err); + return { ok: false, error: err.message }; + } +} + +/** + * Check if WebAuthn is supported + */ +export function isPasskeySupported() { + return !!( + window.PublicKeyCredential && + navigator.credentials && + navigator.credentials.create && + navigator.credentials.get + ); +} + +// Helper functions for base64 <-> ArrayBuffer conversion +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +function base64ToArrayBuffer(base64) { + const padding = "=".repeat((4 - (base64.length % 4)) % 4); + const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +/** + * Start passkey signup - creates WebAuthn registration options + */ +export async function startPasskeySignup({ username, email, firstName, lastName }) { + try { + const res = await apiFetch("/api/passkey/signup/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, email, firstName, lastName }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { ok: false, error: err.error || "Failed to start signup" }; + } + + const data = await res.json(); + return { ok: true, options: data.options, tempUserId: data.tempUserId }; + } catch (err) { + console.error("[Passkey] Start signup error:", err); + return { ok: false, error: err.message }; + } +} + +/** + * Finish passkey signup - validates credential and creates account + */ +export async function finishPasskeySignup(tempUserId, credential) { + try { + // Convert credential to JSON-serializable format + const credentialJSON = { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + response: { + attestationObject: arrayBufferToBase64(credential.response.attestationObject), + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON), + }, + }; + + const res = await apiFetch("/api/passkey/signup/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tempUserId, response: credentialJSON }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { ok: false, error: err.error || "Signup failed" }; + } + + const data = await res.json(); + return { + ok: true, + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresIn: data.expiresIn, + user: data.user, + }; + } catch (err) { + console.error("[Passkey] Finish signup error:", err); + return { ok: false, error: err.message }; + } +} + +/** + * Full passkey signup flow + */ +export async function signupWithPasskey({ username, email, firstName, lastName }) { + // Step 1: Start signup + const startResult = await startPasskeySignup({ username, email, firstName, lastName }); + if (!startResult.ok) { + return startResult; + } + + // Step 2: Call WebAuthn API + try { + const options = startResult.options; + + const publicKeyOptions = { + ...options.publicKey, + challenge: base64ToArrayBuffer(options.publicKey.challenge), + user: { + ...options.publicKey.user, + id: base64ToArrayBuffer(options.publicKey.user.id), + }, + excludeCredentials: (options.publicKey.excludeCredentials || []).map(cred => ({ + ...cred, + id: base64ToArrayBuffer(cred.id), + })), + }; + + const credential = await navigator.credentials.create({ + publicKey: publicKeyOptions, + }); + + if (!credential) { + return { ok: false, error: "Passkey creation cancelled" }; + } + + // Step 3: Finish signup + return await finishPasskeySignup(startResult.tempUserId, credential); + } catch (err) { + console.error("[Passkey] WebAuthn create error:", err); + if (err.name === "NotAllowedError") { + return { ok: false, error: "Passkey creation was cancelled" }; + } + return { ok: false, error: err.message || "Passkey signup failed" }; + } +} diff --git a/src/components/Chat/ChatContainer.css b/src/components/Chat/ChatContainer.css index aadf6a3..8738f00 100644 --- a/src/components/Chat/ChatContainer.css +++ b/src/components/Chat/ChatContainer.css @@ -443,11 +443,13 @@ body[data-theme="light"] .contacts-empty { } .chat-window-wrapper { + position: fixed !important; left: 0 !important; right: 0 !important; + bottom: 0 !important; + top: 35vh !important; /* Never go above 35% from top */ width: 100%; - /* Smooth transition for keyboard adjustment */ - transition: bottom 0.15s ease-out; + overflow: hidden; } /* Hide bottom bar when chat is open to avoid z-index conflicts */ diff --git a/src/components/Chat/ChatWindow.css b/src/components/Chat/ChatWindow.css index 8703edf..718b53d 100644 --- a/src/components/Chat/ChatWindow.css +++ b/src/components/Chat/ChatWindow.css @@ -253,6 +253,7 @@ /* Messages */ .chat-messages { flex: 1; + min-height: 0; /* Required for flex child to scroll properly */ overflow-y: auto; padding: 12px; display: flex; @@ -467,16 +468,27 @@ body[data-theme="light"] .chat-sidebar-avatar.online::after { @media (max-width: 640px) { .chat-window-outer { width: 100%; + height: 100% !important; + max-height: 100% !important; + overflow: hidden; } .chat-window { width: 100%; max-width: 100vw; - height: 50vh; - height: 50dvh; /* Dynamic viewport height - accounts for keyboard */ - max-height: calc(100vh - 60px); - max-height: calc(100dvh - 60px); + height: 100% !important; + max-height: 100% !important; + overflow: hidden; border-radius: 16px 16px 0 0; + display: flex; + flex-direction: column; + } + + .chat-messages { + flex: 1 1 0 !important; + min-height: 0 !important; + max-height: none !important; + overflow-y: auto !important; } .chat-window-outer.with-sidebar .chat-window { diff --git a/src/components/Chat/ChatWindow.jsx b/src/components/Chat/ChatWindow.jsx index e9f37fa..832ab42 100644 --- a/src/components/Chat/ChatWindow.jsx +++ b/src/components/Chat/ChatWindow.jsx @@ -25,14 +25,16 @@ export default function ChatWindow({ }) { const { t } = useTranslation(); const [input, setInput] = useState(""); - const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); const inputRef = useRef(null); const typingTimeout = useRef(null); // Auto-scroll to bottom on new messages useEffect(() => { - if (!minimized && messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + if (!minimized && messagesContainerRef.current) { + // Scroll the messages container directly instead of using scrollIntoView + // scrollIntoView can cause parent containers to scroll on mobile + messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; } }, [messages, minimized]); @@ -163,7 +165,7 @@ export default function ChatWindow({ {/* Messages */} -
+
{messages.length === 0 ? (
{t("chat.startConversation", "Start a conversation")} @@ -208,7 +210,6 @@ export default function ChatWindow({
)} -
{/* Input */} diff --git a/src/components/Chat/ChatWindowNew.jsx b/src/components/Chat/ChatWindowNew.jsx index 2b0fae8..a38170e 100644 --- a/src/components/Chat/ChatWindowNew.jsx +++ b/src/components/Chat/ChatWindowNew.jsx @@ -77,13 +77,28 @@ export default function ChatWindowNew({ }) { const { t } = useTranslation(); const [input, setInput] = useState(""); - const messagesEndRef = useRef(null); + const [keyboardOffset, setKeyboardOffset] = useState(0); + const messagesContainerRef = useRef(null); const inputRef = useRef(null); const typingTimeout = useRef(null); + // Handle mobile keyboard - move chat window up when keyboard opens useEffect(() => { - if (!minimized && messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + if (typeof window === "undefined" || !window.visualViewport) return; + + const handleResize = () => { + // Calculate how much the viewport shrunk (keyboard height) + const keyboardHeight = window.innerHeight - window.visualViewport.height; + setKeyboardOffset(keyboardHeight > 50 ? keyboardHeight : 0); + }; + + window.visualViewport.addEventListener("resize", handleResize); + return () => window.visualViewport.removeEventListener("resize", handleResize); + }, []); + + useEffect(() => { + if (!minimized && messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; } }, [messages, minimized]); @@ -144,7 +159,8 @@ export default function ChatWindowNew({ return ( 0 ? keyboardOffset + 4 : undefined }} variants={windowVariants} initial="hidden" animate="visible" @@ -194,7 +210,7 @@ export default function ChatWindowNew({ {/* Main Window */} -
+
{/* Header */}
@@ -239,7 +255,7 @@ export default function ChatWindowNew({
{/* Messages */} -
+
{messages.length === 0 ? (
@@ -290,7 +306,6 @@ export default function ChatWindowNew({ }) )} {typing && } -
{/* Input */} diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx deleted file mode 100644 index 4d7076b..0000000 --- a/src/components/Layout/TopBar.jsx +++ /dev/null @@ -1,759 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useAuth } from "../../auth/AuthContext"; -import { registerUser, updateProfileUsername, fetchFriendRequests, toSmallImageUrl } from "../../api/client"; -import AuthToast from "../Auth/AuthToast"; -import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall'; -import { trackEvent } from "../../utils/analytics"; -import FriendsList from "../Friends/FriendsList"; - -const SUPPORTED_LANGS = ['en', 'fr', 'es']; -const THEMES = ['dark', 'blue', 'light']; -const LAST_SIGNUP_KEY = "sociowire:lastSignup"; - -function initialLetter(name = "") { - const s = (name || "").trim(); - return s ? s[0].toUpperCase() : "G"; -} - -function truthyParam(v) { - if (!v) return false; - const x = String(v).toLowerCase().trim(); - return x === "1" || x === "true" || x === "yes" || x === "ok"; -} - -function readLastSignup() { - try { - const raw = localStorage.getItem(LAST_SIGNUP_KEY); - if (!raw) return null; - const j = JSON.parse(raw); - if (!j || typeof j !== "object") return null; - return { - username: (j.username || "").toString().trim(), - email: (j.email || "").toString().trim(), - }; - } catch { - return null; - } -} - -function parseMergedParams() { - const search = window.location.search || ""; - const hash = window.location.hash || ""; - - const qs = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); - - let hashQs = ""; - if (hash.includes("?")) hashQs = hash.slice(hash.indexOf("?") + 1); - const hs = new URLSearchParams(hashQs); - - const get = (k) => { - const a = qs.get(k); - if (a != null && a !== "") return a; - const b = hs.get(k); - return b != null ? b : null; - }; - - return { qs, hs, get, hash }; -} - -export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland, onSearch, onMyLocation }) { - const { t, i18n } = useTranslation(); - const { - loading, - authenticated, - username, - login, - logout, - restore, - loginWithProvider, - ssoEnabled, - avatarUrl, - profileLoaded, - needsUsername, - needsEmailVerification, - lastError, - lastInfo, - clearError, - clearInfo, - } = useAuth(); - - const [showLangMenu, setShowLangMenu] = useState(false); - const [showThemeMenu, setShowThemeMenu] = useState(false); - const [showUserMenu, setShowUserMenu] = useState(false); - const langBtnRef = useRef(null); - const themeBtnRef = useRef(null); - const userMenuRef = useRef(null); - - const [searchQuery, setSearchQuery] = useState(""); - const [searchFocused, setSearchFocused] = useState(false); - - const changeLanguage = (lang) => { - i18n.changeLanguage(lang); - setShowLangMenu(false); - }; - - // Close menus when clicking outside - useEffect(() => { - if (!showLangMenu && !showThemeMenu && !showUserMenu) return; - const handleClick = (e) => { - if (showLangMenu && langBtnRef.current && !langBtnRef.current.contains(e.target)) { - setShowLangMenu(false); - } - if (showThemeMenu && themeBtnRef.current && !themeBtnRef.current.contains(e.target)) { - setShowThemeMenu(false); - } - if (showUserMenu && userMenuRef.current && !userMenuRef.current.contains(e.target)) { - setShowUserMenu(false); - } - }; - document.addEventListener("click", handleClick); - return () => document.removeEventListener("click", handleClick); - }, [showLangMenu, showThemeMenu, showUserMenu]); - - const [showAuth, setShowAuth] = useState(false); - const [mode, setMode] = useState("login"); - const [showUsernameModal, setShowUsernameModal] = useState(false); - const [showFriends, setShowFriends] = useState(false); - const [pendingRequestsCount, setPendingRequestsCount] = useState(0); - const [desiredUsername, setDesiredUsername] = useState(""); - const [usernameError, setUsernameError] = useState(""); - const [usernameInfo, setUsernameInfo] = useState(""); - const [usernameSaving, setUsernameSaving] = useState(false); - const usernamePromptTimer = useRef(null); - const [stableUsername, setStableUsername] = useState(""); - - const displayUsername = (() => { - const raw = (stableUsername || username || "user").toString(); - if (!stableUsername && raw.includes("@")) return "user"; - if (raw.length <= 12) return raw; - return raw.slice(0, 12) + ".."; - })(); - - const [loginUser, setLoginUser] = useState(""); - const [loginPass, setLoginPass] = useState(""); - - const [firstName, setFirstName] = useState(""); - const [lastName, setLastName] = useState(""); - const [regUser, setRegUser] = useState(""); - const [regEmail, setRegEmail] = useState(""); - const [regPass, setRegPass] = useState(""); - const [signupError, setSignupError] = useState(""); - const [signupInfo, setSignupInfo] = useState(""); - const [signupLoading, setSignupLoading] = useState(false); - const [showInstallBtn, setShowInstallBtn] = useState(false); - const [authNotice, setAuthNotice] = useState(""); - - const resetPasswordUrl = useMemo(() => { - const base = (import.meta?.env?.VITE_KC_URL || "https://auth.sociowire.com").replace(/\/+$/, ""); - const realm = import.meta?.env?.VITE_KC_REALM || "sociowire"; - const clientId = import.meta?.env?.VITE_KC_CLIENT_ID || "sociowire-frontend"; - const redirectUri = window.location.origin + window.location.pathname; - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - }); - return `${base}/realms/${realm}/login-actions/reset-credentials?${params.toString()}`; - }, []); - - const openModal = (nextMode) => { - setMode(nextMode); - setShowAuth(true); - setSignupError(""); - trackEvent("auth_modal_open", { mode: nextMode || "login" }); - }; - - const closeModal = () => { - setShowAuth(false); - setLoginPass(""); - setSignupError(""); - setAuthNotice(""); - }; - - const handleUsernameSave = async (e) => { - e.preventDefault(); - const next = (desiredUsername || "").trim(); - const re = /^[A-Za-z0-9_.]{3,24}$/; - if (!re.test(next) || next.includes("@")) { - setUsernameError(t('auth.usernameHint')); - return; - } - setUsernameSaving(true); - setUsernameError(""); - setUsernameInfo(""); - const res = await updateProfileUsername(next); - setUsernameSaving(false); - if (!res.ok) { - if (res.status === 409) { - setUsernameError(t('auth.usernameTaken')); - } else { - setUsernameError(res.text || t('auth.usernameUpdateError')); - } - return; - } - setUsernameInfo(t('auth.usernameSaved')); - setShowUsernameModal(false); - await restore(); - }; - - useEffect(() => { - if (authenticated) setShowAuth(false); - }, [authenticated]); - - // Fetch pending friend requests count - useEffect(() => { - if (!authenticated) { - setPendingRequestsCount(0); - return; - } - const loadRequests = async () => { - try { - const res = await fetchFriendRequests(); - const received = (res?.requests || []).filter(r => r?.direction === 'received'); - setPendingRequestsCount(received.length); - } catch (err) { - setPendingRequestsCount(0); - } - }; - loadRequests(); - const interval = setInterval(loadRequests, 30000); - return () => clearInterval(interval); - }, [authenticated]); - - useEffect(() => { - if (!authenticated) { - setStableUsername(""); - return; - } - const next = (username || "").trim(); - if (!next) return; - const looksLikeEmail = next.includes("@"); - if (!looksLikeEmail) { - setStableUsername(next); - return; - } - if (stableUsername) return; - }, [authenticated, username, stableUsername]); - - useEffect(() => { - const requiresUsername = (name) => { - if (!name) return false; - if (name.includes("@")) return true; - const re = /^[A-Za-z0-9_.]{3,24}$/; - return !re.test(name); - }; - if (usernamePromptTimer.current) { - clearTimeout(usernamePromptTimer.current); - usernamePromptTimer.current = null; - } - if (loading) return; - if (!authenticated) { - setShowUsernameModal(false); - return; - } - const shouldPrompt = !!needsUsername || requiresUsername(username); - if (shouldPrompt) { - if (!showUsernameModal) { - const base = (username || "").split("@")[0] || ""; - const cleaned = base.replace(/[^A-Za-z0-9_.]/g, "").slice(0, 24); - setDesiredUsername(cleaned); - setUsernameError(""); - setUsernameInfo(""); - usernamePromptTimer.current = setTimeout(() => { - setShowUsernameModal(true); - }, 600); - } - } else if (showUsernameModal) { - setShowUsernameModal(false); - } - }, [authenticated, username, showUsernameModal, loading, needsUsername]); - - useEffect(() => { - return () => { - if (usernamePromptTimer.current) { - clearTimeout(usernamePromptTimer.current); - usernamePromptTimer.current = null; - } - }; - }, []); - - useEffect(() => { - const handler = (e) => { - const detail = e?.detail || {}; - const msg = (detail.message || "").toString().trim() || t('auth.pleaseSignIn'); - setAuthNotice(msg); - setMode(detail.mode === "signup" ? "signup" : "login"); - setShowAuth(true); - }; - window.addEventListener("sociowire:auth-required", handler); - return () => window.removeEventListener("sociowire:auth-required", handler); - }, []); - - // Check if PWA install should be shown - useEffect(() => { - const checkInstall = () => { - if (isRunningAsPWA()) { - setShowInstallBtn(false); - return; - } - setShowInstallBtn(canInstall()); - }; - checkInstall(); - const handlePrompt = () => setTimeout(checkInstall, 100); - window.addEventListener('beforeinstallprompt', handlePrompt); - window.addEventListener('appinstalled', () => setShowInstallBtn(false)); - return () => { - window.removeEventListener('beforeinstallprompt', handlePrompt); - window.removeEventListener('appinstalled', () => setShowInstallBtn(false)); - }; - }, []); - - useEffect(() => { - try { - const { get, qs, hs, hash } = parseMergedParams(); - const verified = get("verified") || get("validated") || get("email_verified") || get("emailVerified") || get("kc_verified"); - const signup = get("signup"); - const notice = get("notice"); - const email = get("email") || get("user_email") || get("mail"); - const uname = get("username") || get("user") || get("preferred_username") || get("login"); - const last = readLastSignup(); - - if (truthyParam(verified)) { - const who = (email || uname || last?.email || last?.username || "").trim(); - setSignupInfo("✅ " + t('auth.emailConfirmedMsg') + (who ? " (" + who + ")" : "")); - setMode("login"); - setShowAuth(true); - const prefill = (uname || email || last?.username || last?.email || "").trim(); - if (prefill) setLoginUser(prefill); - } else if (truthyParam(signup)) { - try { localStorage.removeItem(LAST_SIGNUP_KEY); } catch {} - setSignupInfo(""); - setSignupError(""); - setAuthNotice(""); - setLoginUser(""); - setMode("signup"); - setShowAuth(true); - } else if (notice) { - const msg = decodeURIComponent(notice); - if (msg && msg.trim()) { - setSignupInfo(msg.trim()); - setMode("login"); - setShowAuth(true); - } - } - - const touched = qs.has("verified") || qs.has("validated") || qs.has("email_verified") || qs.has("emailVerified") || qs.has("signup") || qs.has("notice") || - hs.has("verified") || hs.has("validated") || hs.has("email_verified") || hs.has("emailVerified") || hs.has("signup") || hs.has("notice"); - - if (touched) { - const cleanHash = hash.includes("?") ? hash.split("?")[0] : hash; - const clean = window.location.pathname + cleanHash; - window.history.replaceState({}, "", clean); - } - } catch (e) {} - }, []); - - const handleLoginSubmit = async (e) => { - e.preventDefault(); - trackEvent("login_submit"); - const res = await login(loginUser, loginPass); - if (res?.ok) trackEvent("login_success"); - }; - - const handleSignupSubmit = async (e) => { - e.preventDefault(); - setSignupError(""); - setSignupInfo(""); - - if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) { - setSignupError(t('auth.fieldsRequired')); - return; - } - - try { - setSignupLoading(true); - trackEvent("signup_submit"); - const u = regUser.trim(); - const em = regEmail.trim(); - await registerUser({ username: u, email: em, password: regPass, firstName: firstName.trim(), lastName: lastName.trim() }); - try { localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em })); } catch {} - setSignupInfo("✅ " + t('auth.accountCreatedMsg') + " (" + em + ")"); - trackEvent("signup_success"); - setMode("login"); - setLoginUser(u || em); - setLoginPass(""); - } catch (err) { - setSignupError(err.message || t('auth.registrationError')); - } finally { - setSignupLoading(false); - } - }; - - const handleSearchSubmit = (e) => { - e.preventDefault(); - if (searchQuery.trim() && onSearch) { - onSearch(searchQuery.trim()); - } - }; - - const toast = useMemo(() => { - if (lastError) { - const toastType = needsEmailVerification ? "warn" : "error"; - const title = needsEmailVerification ? t('auth.emailVerificationRequired') : t('auth.loginFailed'); - return { type: toastType, title, message: lastError }; - } - if (lastInfo) { - return { type: "success", title: "", message: lastInfo }; - } - return null; - }, [lastError, lastInfo, needsEmailVerification, t]); - - const themeIcon = theme === 'dark' ? 'fa-moon' : theme === 'light' ? 'fa-sun' : 'fa-droplet'; - - return ( - <> - {/* Toast area */} - {toast?.message && ( -
- { - if (lastError && typeof clearError === "function") clearError(); - if (lastInfo && typeof clearInfo === "function") clearInfo(); - }} - /> -
- )} - -
- {/* LEFT: Logo + Brand */} -
-
- SocioWire -
-
- SocioWire - {t('topbar.wiredToLife')} -
-
- - {/* CENTER: Search Bar */} -
-
- - setSearchQuery(e.target.value)} - onFocus={() => setSearchFocused(true)} - onBlur={() => setSearchFocused(false)} - placeholder={t('map.searchPlaceholder')} - className="sw-search-input" - /> - {searchQuery && ( - - )} - -
- - {/* RIGHT: Actions */} -
- {/* My Location */} - - - {/* Islands */} - - - {/* Install PWA */} - {showInstallBtn && ( - - )} - - {/* Theme */} -
- - {showThemeMenu && ( -
- {THEMES.map((themeKey) => ( - - ))} -
- )} -
- - {/* Language */} -
- - {showLangMenu && ( -
- {SUPPORTED_LANGS.map((lang) => ( - - ))} -
- )} -
- - {/* Friends - only when authenticated */} - {authenticated && ( - - )} - - {/* User / Login */} -
- - {showUserMenu && authenticated && ( -
- - -
- )} -
-
-
- - {/* Auth Modal */} - {showAuth && ( -
-
e.stopPropagation()}> -
- - - -
- - {signupInfo &&
{signupInfo}
} - {authNotice &&
{authNotice}
} - {mode === "login" && lastError && ( -
- {lastError} -
- )} - - {mode === "login" ? ( -
- - - - - - {needsEmailVerification && ( -
- ⚠️ {t('auth.verifyEmail')} -
- )} - - {ssoEnabled && ( - <> -
{t('auth.orContinueWith')}
-
- - -
- - )} -
- ) : ( -
-
- - -
- - - - {signupError &&
{signupError}
} - -
- )} -
-
- )} - - {/* Username Modal */} - {showUsernameModal && ( -
{}}> -
e.stopPropagation()}> -
-
{t('auth.chooseUsername')}
-
-
{t('auth.pickUsername')}
- {usernameError &&
{usernameError}
} - {usernameInfo &&
{usernameInfo}
} -
- - -
-
-
- )} - - {/* Friends Panel */} - {showFriends && ( -
- { setShowFriends(false); if (onUserIsland) onUserIsland(uname); }} - onClose={() => setShowFriends(false)} - /> -
- )} - - ); -} diff --git a/src/components/Layout/TopBarNew.jsx b/src/components/Layout/TopBarNew.jsx index 73e9cbd..507d87e 100644 --- a/src/components/Layout/TopBarNew.jsx +++ b/src/components/Layout/TopBarNew.jsx @@ -184,8 +184,25 @@ function DropdownOption({ active, onClick, children }) { ); } -function UserChip({ authenticated, loading, username, avatarUrl, onClick }) { +function UserChip({ authenticated, loading, username, avatarUrl: propAvatarUrl, onClick }) { const { t } = useTranslation(); + const [localAvatar, setLocalAvatar] = useState(null); + + // Fetch avatar from island if not provided + useEffect(() => { + if (authenticated && username && !propAvatarUrl) { + fetch(`${import.meta.env.VITE_API_BASE_URL || ''}/api/islands/by-username?username=${encodeURIComponent(username)}`, { + credentials: 'include' + }) + .then(res => res.ok ? res.json() : null) + .then(data => { + if (data?.avatar_url) setLocalAvatar(data.avatar_url); + }) + .catch(() => {}); + } + }, [authenticated, username, propAvatarUrl]); + + const avatarUrl = propAvatarUrl || localAvatar; const displayUsername = useMemo(() => { const raw = (username || "user").toString(); @@ -210,7 +227,7 @@ function UserChip({ authenticated, loading, username, avatarUrl, onClick }) {
{avatarUrl ? ( - {username} + {username} { console.log("[TopBar] Avatar load error:", avatarUrl); e.target.style.display = 'none'; }} /> ) : (
{authenticated ? initialLetter(username) : } @@ -240,12 +257,16 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic const { loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled, avatarUrl, profileLoaded, needsUsername, needsEmailVerification, lastError, lastInfo, clearError, clearInfo, + passkeySupported, hasPasskey, loginWithPasskey, signupWithPasskey, registerNewPasskey, } = useAuth(); // Dropdown states const [showLangMenu, setShowLangMenu] = useState(false); const [langMenuPos, setLangMenuPos] = useState({ top: 0, right: 0 }); const langBtnRef = useRef(null); + const [showUserMenu, setShowUserMenu] = useState(false); + const [userMenuPos, setUserMenuPos] = useState({ top: 0, right: 0 }); + const userChipRef = useRef(null); const [showThemeMenu, setShowThemeMenu] = useState(false); const [themeMenuPos, setThemeMenuPos] = useState({ top: 0, right: 0 }); @@ -262,6 +283,9 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic // Form states const [loginUser, setLoginUser] = useState(""); const [loginPass, setLoginPass] = useState(""); + const [userHasPasskey, setUserHasPasskey] = useState(false); + const [checkingPasskey, setCheckingPasskey] = useState(false); + const passkeyCheckTimer = useRef(null); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [regUser, setRegUser] = useState(""); @@ -275,6 +299,175 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic const [usernameError, setUsernameError] = useState(""); const [usernameInfo, setUsernameInfo] = useState(""); const [usernameSaving, setUsernameSaving] = useState(false); + const [showPasskeyPrompt, setShowPasskeyPrompt] = useState(false); + const [passkeySetupLoading, setPasskeySetupLoading] = useState(false); + const [passkeySetupError, setPasskeySetupError] = useState(""); + const legacyLoginPending = useRef(false); + const PASSKEY_PROMPT_KEY = "sociowire:passkeyPromptPending"; + + // New unified auth flow states + const [authStep, setAuthStep] = useState("identify"); // "identify" | "authenticate" | "register" + const [identifier, setIdentifier] = useState(""); + const [userExists, setUserExists] = useState(false); + const [resolvedUsername, setResolvedUsername] = useState(""); + const [savedSessions, setSavedSessions] = useState([]); + const [rememberAccount, setRememberAccount] = useState(true); + const [signupPass, setSignupPass] = useState(""); + const [userAvatarUrl, setUserAvatarUrl] = useState(""); + const SAVED_SESSIONS_KEY = "sociowire:savedSessions"; + + // Load saved sessions from localStorage and fetch missing avatars + useEffect(() => { + const loadSessions = async () => { + try { + const saved = JSON.parse(localStorage.getItem(SAVED_SESSIONS_KEY) || "[]"); + const sessions = Array.isArray(saved) ? saved.slice(0, 5) : []; + setSavedSessions(sessions); + + // Fetch avatars for sessions that don't have one + for (const session of sessions) { + if (!session.avatarUrl && session.username) { + try { + const res = await fetch(`${import.meta.env.VITE_API_BASE_URL || ''}/api/islands/by-username?username=${encodeURIComponent(session.username)}`); + if (res.ok) { + const data = await res.json(); + if (data?.avatar_url) { + // Update in localStorage and state + const current = JSON.parse(localStorage.getItem(SAVED_SESSIONS_KEY) || "[]"); + const updated = current.map(s => s.username === session.username ? { ...s, avatarUrl: data.avatar_url } : s); + localStorage.setItem(SAVED_SESSIONS_KEY, JSON.stringify(updated)); + setSavedSessions(updated.slice(0, 5)); + } + } + } catch {} + } + } + } catch { setSavedSessions([]); } + }; + loadSessions(); + }, []); + + // Save session after successful login + const saveSession = (uname, email, avatar) => { + try { + const saved = JSON.parse(localStorage.getItem(SAVED_SESSIONS_KEY) || "[]"); + const filtered = saved.filter(s => s.username !== uname); + const newSessions = [{ username: uname, email, avatarUrl: avatar || "", lastLogin: Date.now() }, ...filtered].slice(0, 5); + localStorage.setItem(SAVED_SESSIONS_KEY, JSON.stringify(newSessions)); + setSavedSessions(newSessions); + } catch {} + }; + + // Update avatar for existing saved session + const updateSessionAvatar = (uname, avatar) => { + if (!avatar || !uname) return; + try { + const saved = JSON.parse(localStorage.getItem(SAVED_SESSIONS_KEY) || "[]"); + const updated = saved.map(s => s.username === uname ? { ...s, avatarUrl: avatar } : s); + localStorage.setItem(SAVED_SESSIONS_KEY, JSON.stringify(updated)); + setSavedSessions(updated); + } catch {} + }; + + // Update avatar in saved sessions when user logs in and avatar loads + useEffect(() => { + if (authenticated && username && avatarUrl) { + updateSessionAvatar(username, avatarUrl); + } + }, [authenticated, username, avatarUrl]); + + // Fetch user avatar + const fetchUserAvatar = async (uname) => { + try { + const res = await fetch(`${import.meta.env.VITE_API_BASE_URL || ''}/api/islands/by-username?username=${encodeURIComponent(uname)}`); + if (res.ok) { + const data = await res.json(); + if (data?.avatar_url) { + setUserAvatarUrl(data.avatar_url); + return data.avatar_url; + } + } + } catch {} + setUserAvatarUrl(""); + return ""; + }; + + // Check user and move to next step + const handleIdentifyNext = async () => { + if (!identifier.trim()) return; + setCheckingPasskey(true); + setSignupError(""); + setUserAvatarUrl(""); + try { + const res = await hasPasskey(identifier.trim()); + setUserExists(res.userExists === true); + setUserHasPasskey(res.hasPasskey === true); + setResolvedUsername(res.username || identifier.trim()); + if (res.userExists) { + // Fetch avatar for the user + fetchUserAvatar(res.username || identifier.trim()); + setAuthStep("authenticate"); + } else { + // Pre-fill signup form + if (identifier.includes("@")) { + setRegEmail(identifier.trim()); + setRegUser(""); + } else { + setRegUser(identifier.trim()); + setRegEmail(""); + } + setAuthStep("register"); + } + } catch (err) { + setSignupError("Erreur de vérification"); + } finally { + setCheckingPasskey(false); + } + }; + + // Select saved session - directly go to authenticate step + const handleSelectSavedSession = async (session) => { + setIdentifier(session.username); + setCheckingPasskey(true); + setSignupError(""); + // Use saved avatar or fetch it + if (session.avatarUrl) { + setUserAvatarUrl(session.avatarUrl); + } else { + fetchUserAvatar(session.username); + } + try { + const res = await hasPasskey(session.username); + setUserExists(res.userExists === true); + setUserHasPasskey(res.hasPasskey === true); + setResolvedUsername(res.username || session.username); + // Go directly to authenticate since they're a saved session (known user) + setAuthStep("authenticate"); + } catch (err) { + // Fallback to identify step + setAuthStep("identify"); + } finally { + setCheckingPasskey(false); + } + }; + + // Reset auth modal + const resetAuthModal = () => { + setAuthStep("identify"); + setIdentifier(""); + setUserExists(false); + setUserHasPasskey(false); + setResolvedUsername(""); + setLoginPass(""); + setSignupPass(""); + setSignupError(""); + setAuthNotice(""); + setFirstName(""); + setLastName(""); + setRegUser(""); + setRegEmail(""); + setRememberAccount(true); + }; const usernamePromptTimer = useRef(null); @@ -298,17 +491,15 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic }; const openModal = (nextMode) => { - setMode(nextMode); + resetAuthModal(); + setMode(nextMode || "login"); setShowAuth(true); - setSignupError(""); trackEvent("auth_modal_open", { mode: nextMode || "login" }); }; const closeModal = () => { setShowAuth(false); - setLoginPass(""); - setSignupError(""); - setAuthNotice(""); + resetAuthModal(); }; // Reset password URL @@ -322,8 +513,32 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic // Effects useEffect(() => { - if (authenticated) setShowAuth(false); - }, [authenticated]); + if (authenticated) { + setShowAuth(false); + // Check if passkey prompt is pending (from social login or password login) + const socialLoginPending = sessionStorage.getItem(PASSKEY_PROMPT_KEY); + const shouldPrompt = legacyLoginPending.current || socialLoginPending; + + if (shouldPrompt && passkeySupported && registerNewPasskey) { + legacyLoginPending.current = false; + sessionStorage.removeItem(PASSKEY_PROMPT_KEY); + // Check if user already has a passkey before prompting + const checkAndPrompt = async () => { + if (hasPasskey && username) { + const result = await hasPasskey(username); + if (result?.hasPasskey) return; // Already has passkey, don't prompt + } + // Small delay to let username modal potentially appear first + setTimeout(() => { + if (!showUsernameModal) { + setShowPasskeyPrompt(true); + } + }, 800); + }; + checkAndPrompt(); + } + } + }, [authenticated, passkeySupported, registerNewPasskey, showUsernameModal, hasPasskey, username]); useEffect(() => { if (!authenticated) { setPendingRequestsCount(0); return; } @@ -395,19 +610,69 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic } }; + // Check for passkey when username changes + const checkUserPasskey = async (user) => { + if (!user || !passkeySupported || !hasPasskey) { + setUserHasPasskey(false); + return; + } + setCheckingPasskey(true); + try { + const result = await hasPasskey(user); + setUserHasPasskey(result.hasPasskey); + } catch { + setUserHasPasskey(false); + } + setCheckingPasskey(false); + }; + + const handleLoginUserChange = (e) => { + const val = e.target.value; + setLoginUser(val); + if (passkeyCheckTimer.current) clearTimeout(passkeyCheckTimer.current); + if (val.length >= 3) { + passkeyCheckTimer.current = setTimeout(() => checkUserPasskey(val), 500); + } else { + setUserHasPasskey(false); + } + }; + + const handlePasskeyLogin = async () => { + trackEvent("passkey_login_attempt"); + const userToLogin = resolvedUsername || identifier || loginUser; + const res = await loginWithPasskey(userToLogin); + if (res?.ok) { + trackEvent("passkey_login_success"); + if (rememberAccount) { + saveSession(userToLogin, identifier.includes("@") ? identifier : "", ""); + } + closeModal(); + } + }; + const handleLoginSubmit = async (e) => { e.preventDefault(); trackEvent("login_submit"); - const res = await login(loginUser, loginPass); - if (res?.ok) trackEvent("login_success"); + legacyLoginPending.current = true; // Mark as legacy login for passkey prompt + const userToLogin = resolvedUsername || identifier || loginUser; + const res = await login(userToLogin, loginPass); + if (res?.ok) { + trackEvent("login_success"); + if (rememberAccount) { + saveSession(userToLogin, identifier.includes("@") ? identifier : "", ""); + } + closeModal(); + } else { + legacyLoginPending.current = false; + } }; const handleSignupSubmit = async (e) => { - e.preventDefault(); + if (e) e.preventDefault(); setSignupError(""); setSignupInfo(""); - if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) { - setSignupError(t('auth.fieldsRequired')); + if (!regUser.trim() || !signupPass) { + setSignupError(t('auth.usernameAndPasswordRequired', 'Username and password required')); return; } try { @@ -415,7 +680,7 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic trackEvent("signup_submit"); const u = regUser.trim(); const em = regEmail.trim(); - await registerUser({ username: u, email: em, password: regPass, firstName: firstName.trim(), lastName: lastName.trim() }); + await registerUser({ username: u, email: em, password: signupPass, firstName: firstName.trim(), lastName: lastName.trim() }); try { localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em })); } catch {} setSignupInfo("✅ " + t('auth.accountCreatedMsg') + " (" + em + ")"); trackEvent("signup_success"); @@ -429,6 +694,63 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic } }; + const handlePasskeySignup = async (e) => { + if (e) e.preventDefault(); + setSignupError(""); + setSignupInfo(""); + if (!regUser.trim() || !firstName.trim() || !lastName.trim() || !regEmail.trim()) { + setSignupError(t('auth.fieldsRequired')); + return; + } + try { + setSignupLoading(true); + trackEvent("passkey_signup_submit"); + const res = await signupWithPasskey({ + username: regUser.trim(), + email: regEmail.trim() || undefined, + firstName: firstName.trim() || undefined, + lastName: lastName.trim() || undefined, + }); + if (res?.ok) { + trackEvent("passkey_signup_success"); + if (rememberAccount) { + saveSession(regUser.trim(), regEmail.trim() || "", ""); + } + closeModal(); + } else { + setSignupError(res?.error || t('auth.registrationError')); + } + } catch (err) { + console.error("[Passkey] Signup error:", err); + setSignupError(err?.message || t('auth.registrationError')); + } finally { + setSignupLoading(false); + } + }; + + const handlePasskeySetup = async () => { + // Open the modal first to show feedback + setShowPasskeyPrompt(true); + setPasskeySetupLoading(true); + setPasskeySetupError(""); + trackEvent("passkey_setup_attempt"); + try { + const res = await registerNewPasskey("Main Passkey"); + if (res?.ok) { + trackEvent("passkey_setup_success"); + setPasskeySetupError(""); + // Close modal after short delay to show success + setTimeout(() => setShowPasskeyPrompt(false), 1500); + } else { + const errMsg = res?.error || "Unknown error"; + setPasskeySetupError(errMsg); + } + } catch (err) { + setPasskeySetupError(err?.message || "Unknown error"); + } + setPasskeySetupLoading(false); + }; + const handleUsernameSave = async (e) => { e.preventDefault(); const next = (desiredUsername || "").trim(); @@ -539,9 +861,9 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic {showInstallBtn && ( promptInstall()} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} @@ -606,26 +928,67 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic {/* User/Auth */} {authenticated ? ( -
- onUserIsland?.(username)} - /> - { trackEvent("logout_click"); logout(); }} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - title={t('topbar.logout')} - > - - +
+
+ { + const rect = userChipRef.current?.getBoundingClientRect(); + if (rect) { + setUserMenuPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right }); + } + setShowUserMenu(!showUserMenu); + }} + /> +
+ + {/* User dropdown menu */} + + {showUserMenu && ( + <> +
setShowUserMenu(false)} /> + +
+
@{username}
+
+
+ + {passkeySupported && ( + + )} + +
+
+ + )} +
) : ( -
+
- + {t('topbar.login')} )} @@ -691,7 +1054,7 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic )} - {/* Auth Modal */} + {/* Auth Modal - Unified Flow */} {showAuth && ( e.stopPropagation()} > - {/* Header tabs */} -
- - - + )} +

+ {authStep === "identify" && t('auth.welcome', 'Bienvenue')} + {authStep === "authenticate" && t('auth.login', 'Connexion')} + {authStep === "register" && t('auth.createAccount', 'Créer un compte')} +

+
+
{/* Notices */} + {signupError &&
{signupError}
} {signupInfo &&
{signupInfo}
} - {authNotice &&
{authNotice}
} - {mode === "login" && lastError && ( -
- {lastError} -
+ {lastError && authStep === "authenticate" && ( +
{lastError}
)} - {/* Forms */}
- {mode === "login" ? ( -
+ {/* Step 1: Identify */} + {authStep === "identify" && ( +
+ {/* Saved sessions */} + {savedSessions.length > 0 && ( +
+ {t('auth.recentAccounts', 'Comptes récents')} + {savedSessions.map((session) => ( + handleSelectSavedSession(session)} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + > +
+ {session.avatarUrl ? ( + + ) : ( + session.username[0].toUpperCase() + )} +
+
+
@{session.username}
+ {session.email &&
{session.email}
} +
+ +
+ ))} +
+
+ {t('auth.orNewAccount', 'ou autre compte')} +
+
+
+ )} + + {/* Identifier input */} - - + - {loading ? "..." : t('auth.signIn')} + {checkingPasskey ? : } + {checkingPasskey ? t('auth.checking', 'Vérification...') : t('auth.next', 'Suivant')} +
+ )} + {/* Step 2a: Authenticate (user exists) */} + {authStep === "authenticate" && ( +
+ {/* User info */} +
+
+ {userAvatarUrl ? ( + + ) : ( + resolvedUsername[0]?.toUpperCase() || "?" + )} +
+
+
@{resolvedUsername}
+
{identifier}
+
+
+ + {/* Passkey login - primary if available */} + {userHasPasskey && ( + + + {loading ? t('auth.checkingPasskey') : t('auth.loginWithPasskey')} + + )} + + {/* Password login - direct API call */} + + + + + + {loading ? "..." : t('auth.signIn')} + + + + + {/* Google SSO */} {ssoEnabled && ( <> -
+
{t('auth.orContinueWith')}
-
- loginWithProvider?.("google")} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - - Google - - loginWithProvider?.("facebook")} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - - Facebook - -
+ { + if (rememberAccount) { + saveSession(resolvedUsername, identifier.includes("@") ? identifier : "", ""); + } + sessionStorage.setItem(PASSKEY_PROMPT_KEY, "1"); + loginWithProvider?.("google"); + }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + + Google + )} - - ) : ( -
-
+
+ )} + + {/* Step 2b: Register (user doesn't exist) */} + {authStep === "register" && ( +
+

+ {t('auth.noAccountFound')} +

+ + {/* Form fields */} +
+
+ + +
+ +
- - - - {signupError &&
{signupError}
} - - {signupLoading ? "..." : t('auth.createAccount')} - - + + {/* Signup buttons */} +
+ {/* Passkey signup */} + {passkeySupported && ( + + + {signupLoading ? "..." : t('auth.createWithPasskey')} + + )} + + {/* Password signup */} + + + {signupLoading ? "..." : t('auth.createWithPassword')} + + + {/* Google signup */} + {ssoEnabled && ( + { sessionStorage.setItem(PASSKEY_PROMPT_KEY, "1"); loginWithProvider?.("google"); }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + + Google + + )} +
+
)}
@@ -908,6 +1454,67 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic )} + + {/* Passkey Setup Prompt Modal */} + + {showPasskeyPrompt && ( + setShowPasskeyPrompt(false)} + > + e.stopPropagation()} + > +
+
+ +
+

{t('auth.setupPasskey')}

+

{t('auth.setupPasskeyPrompt')}

+
+ + {passkeySetupError && ( +
+ {passkeySetupError} +
+ )} + +
+ + + {passkeySetupLoading ? "..." : t('auth.setupPasskeyNow')} + + + setShowPasskeyPrompt(false)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + {t('auth.skipForNow')} + +
+
+
+ )} +
); } diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index ccadd0b..bdb5234 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -1358,7 +1358,7 @@ export default function MapView({ if (q) { setMainFilters([]); setSubFilters([]); - setTimeHours(336); + // Don't reset timeHours - keep user's current time filter } }; @@ -1368,11 +1368,11 @@ export default function MapView({ setSearchQuery(query); if (!query.trim()) setForceFullPostId(null); - // Clear ALL filters to show all search results + // Clear category filters to show all search results, but KEEP time filter if (query.trim()) { setMainFilters([]); setSubFilters([]); - setTimeHours(336); // 2 weeks + // Don't reset timeHours - keep user's current time filter } }; diff --git a/src/components/Map/mapGeo.js b/src/components/Map/mapGeo.js index facb21a..7fd9c5d 100644 --- a/src/components/Map/mapGeo.js +++ b/src/components/Map/mapGeo.js @@ -31,7 +31,7 @@ export function getViewFromMap(map) { radiusKm = 750; } - radiusKm = Math.min(Math.max(radiusKm * 1.2, 25), 2500); + radiusKm = Math.min(Math.max(radiusKm * 1.05, 15), 2500); return { center: [center.lng, center.lat], diff --git a/src/components/Posts/PostList.jsx b/src/components/Posts/PostList.jsx index 90b455a..295e7ec 100644 --- a/src/components/Posts/PostList.jsx +++ b/src/components/Posts/PostList.jsx @@ -1,72 +1,66 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import PostCard from "./PostCardNew"; import { FeedSkeleton } from "../ui/Skeleton"; import { matchesContentFilter } from "../Filters/ContentFilters"; -const listVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { staggerChildren: 0.08 }, - }, -}; - -const itemVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] } }, -}; - export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated, contentFilter = "all" }) { const { t } = useTranslation(); - const [visibleCount, setVisibleCount] = useState(1); - const listRef = useRef(null); - const sentinelRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(2); + const observerRef = useRef(null); const filtered = useMemo( () => posts.filter((p) => matchesContentFilter(p, contentFilter)), [posts, contentFilter] ); + // Reset when filter changes useEffect(() => { - setVisibleCount(1); + setVisibleCount(2); }, [contentFilter]); - useEffect(() => { - const sentinel = sentinelRef.current; - if (!sentinel) return; - const observer = new IntersectionObserver( - (entries) => { - const hit = entries && entries[0]; - if (!hit || !hit.isIntersecting) return; - setVisibleCount((c) => Math.min(filtered.length, c + 2)); - }, - { root: null, rootMargin: "400px 0px", threshold: 0 } - ); - observer.observe(sentinel); - return () => observer.disconnect(); - }, [filtered.length]); + // Callback ref for sentinel - attaches observer when element mounts + const sentinelRef = useCallback((node) => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + if (!node) return; + + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setVisibleCount((c) => c + 2); + } + }, + { rootMargin: "100px" } + ); + observerRef.current.observe(node); + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, []); - // Show skeleton loading state if (loading && posts.length === 0) { return ( -
- +
+
); } + const visiblePosts = filtered.slice(0, visibleCount); + const hasMore = visibleCount < filtered.length; + return ( -
+
{/* Header */} - +
@@ -78,85 +72,50 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
{loading && ( - +
{t('common.loading')} - +
)}
- +
{/* Error */} - - {error && ( - - - {error} - - )} - + {error && ( +
+ + {error} +
+ )} {/* Posts */} - - - {filtered.slice(0, visibleCount).map((p) => ( - - - - ))} - - - - {/* Empty state */} - - {!loading && filtered.length === 0 && ( - - -

{t('feed.noPosts', 'No posts found')}

-
- )} -
+
+ {visiblePosts.map((p) => ( +
+ +
+ ))} +
{/* Load more sentinel */} - {filtered.length > visibleCount && ( -
- - - {t('feed.loadingMore', 'Loading more...')} - + {hasMore && ( +
+ + Loading... +
+ )} + + {/* Empty state */} + {!loading && filtered.length === 0 && ( +
+ +

{t('feed.noPosts', 'No posts found')}

)}
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0cfad39..e779d1c 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -13,6 +13,7 @@ "search": "Search", "clear": "Clear", "online": "Online", + "offline": "Offline", "guest": "Guest", "anon": "anon", "contacts": "Contacts", @@ -41,7 +42,9 @@ "installApp": "Install App", "friends": "Friends", "login": "Login", - "logout": "Logout" + "logout": "Logout", + "myIsland": "My Island", + "addPasskey": "Add Face ID / Fingerprint" }, "theme": { "dark": "Dark", @@ -53,6 +56,16 @@ "signup": "Sign up", "signIn": "Sign in", "createAccount": "Create account", + "welcome": "Welcome", + "recentAccounts": "Recent accounts", + "orNewAccount": "or other account", + "next": "Next", + "checking": "Checking...", + "noAccountFound": "No account found. Create a new one:", + "forRecovery": "for recovery", + "createWithPasskey": "Face ID / Fingerprint", + "createWithPassword": "Create with password", + "rememberAccount": "Remember this account", "usernameOrEmail": "Username or email", "password": "Password", "forgotPassword": "Forgot password?", @@ -80,7 +93,23 @@ "verifyEmail": "Your account requires email verification. Confirm your email, then try logging in again.", "fieldsRequired": "First name, last name, username, email and password are required.", "registrationError": "Registration error.", - "pleaseSignIn": "Please sign in or create a free account to continue." + "pleaseSignIn": "Please sign in or create a free account to continue.", + "loginWithPasskey": "Face ID / Fingerprint", + "orOtherMethod": "or other method", + "usePassword": "Password", + "checkingPasskey": "Checking...", + "passkeyError": "Passkey authentication error", + "passwordlessSignup": "Passwordless signup!", + "passkeyAfterSignup": "Sign up with Google or Facebook, then set up a passkey for quick login.", + "signupWithGoogle": "Sign up with Google", + "signupWithFacebook": "Sign up with Facebook", + "setupPasskey": "Set up Face ID / Fingerprint", + "setupPasskeyPrompt": "Use Face ID or fingerprint for faster login next time.", + "setupPasskeyNow": "Set up now", + "skipForNow": "Later", + "passkeySetupSuccess": "Passkey set up successfully!", + "passkeySetupError": "Error setting up passkey", + "usernameRequired": "Username required" }, "filters": { "all": "All", @@ -315,7 +344,10 @@ "typeMessage": "Type a message...", "online": "Online", "offline": "Offline", - "typing": "typing..." + "typing": "typing...", + "sending": "Sending...", + "sent": "Sent", + "read": "Read" }, "postModal": { "readArticle": "Read full article", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 40186a7..02701de 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -13,6 +13,7 @@ "search": "Buscar", "clear": "Borrar", "online": "En línea", + "offline": "Desconectado", "guest": "Invitado", "anon": "anon", "contacts": "Contactos", @@ -41,7 +42,9 @@ "installApp": "Instalar App", "friends": "Amigos", "login": "Iniciar sesión", - "logout": "Cerrar sesión" + "logout": "Cerrar sesión", + "myIsland": "Mi Isla", + "addPasskey": "Añadir Face ID / Huella" }, "theme": { "dark": "Oscuro", @@ -53,6 +56,16 @@ "signup": "Registrarse", "signIn": "Entrar", "createAccount": "Crear cuenta", + "welcome": "Bienvenido", + "recentAccounts": "Cuentas recientes", + "orNewAccount": "u otra cuenta", + "next": "Siguiente", + "checking": "Verificando...", + "noAccountFound": "No se encontró cuenta. Crea una nueva:", + "forRecovery": "para recuperación", + "createWithPasskey": "Face ID / Huella", + "createWithPassword": "Crear con contraseña", + "rememberAccount": "Recordar esta cuenta", "usernameOrEmail": "Usuario o email", "password": "Contraseña", "forgotPassword": "¿Olvidaste tu contraseña?", @@ -80,7 +93,23 @@ "verifyEmail": "Tu cuenta requiere verificación de email. Confirma tu email e intenta iniciar sesión de nuevo.", "fieldsRequired": "Nombre, apellido, usuario, email y contraseña son requeridos.", "registrationError": "Error de registro.", - "pleaseSignIn": "Inicia sesión o crea una cuenta gratuita para continuar." + "pleaseSignIn": "Inicia sesión o crea una cuenta gratuita para continuar.", + "loginWithPasskey": "Face ID / Huella", + "orOtherMethod": "u otro método", + "usePassword": "Contraseña", + "checkingPasskey": "Verificando...", + "passkeyError": "Error de autenticación Passkey", + "passwordlessSignup": "¡Registro sin contraseña!", + "passkeyAfterSignup": "Regístrate con Google o Facebook, luego configura un passkey para inicio rápido.", + "signupWithGoogle": "Registrarse con Google", + "signupWithFacebook": "Registrarse con Facebook", + "setupPasskey": "Configurar Face ID / Huella", + "setupPasskeyPrompt": "Usa Face ID o tu huella para iniciar sesión más rápido.", + "setupPasskeyNow": "Configurar ahora", + "skipForNow": "Más tarde", + "passkeySetupSuccess": "¡Passkey configurado con éxito!", + "passkeySetupError": "Error al configurar passkey", + "usernameRequired": "Nombre de usuario requerido" }, "filters": { "all": "Todo", @@ -311,7 +340,10 @@ "typeMessage": "Escribe un mensaje...", "online": "En línea", "offline": "Desconectado", - "typing": "escribiendo..." + "typing": "escribiendo...", + "sending": "Enviando...", + "sent": "Enviado", + "read": "Leído" }, "postModal": { "readArticle": "Leer artículo completo", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index da502c1..96fed76 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -13,6 +13,7 @@ "search": "Rechercher", "clear": "Effacer", "online": "En ligne", + "offline": "Hors ligne", "guest": "Invité", "anon": "anon", "contacts": "Contacts", @@ -41,7 +42,9 @@ "installApp": "Installer l'app", "friends": "Amis", "login": "Connexion", - "logout": "Déconnexion" + "logout": "Déconnexion", + "myIsland": "Mon île", + "addPasskey": "Ajouter Face ID / Empreinte" }, "theme": { "dark": "Sombre", @@ -53,6 +56,16 @@ "signup": "Inscription", "signIn": "Se connecter", "createAccount": "Créer un compte", + "welcome": "Bienvenue", + "recentAccounts": "Comptes récents", + "orNewAccount": "ou autre compte", + "next": "Suivant", + "checking": "Vérification...", + "noAccountFound": "Aucun compte trouvé. Créez-en un nouveau:", + "forRecovery": "pour récupération", + "createWithPasskey": "Face ID / Empreinte", + "createWithPassword": "Créer avec mot de passe", + "rememberAccount": "Se souvenir de ce compte", "usernameOrEmail": "Nom d'utilisateur ou email", "password": "Mot de passe", "forgotPassword": "Mot de passe oublié ?", @@ -80,7 +93,23 @@ "verifyEmail": "Votre compte nécessite une vérification email. Confirmez votre email, puis réessayez de vous connecter.", "fieldsRequired": "Prénom, nom, nom d'utilisateur, email et mot de passe sont requis.", "registrationError": "Erreur d'inscription.", - "pleaseSignIn": "Connectez-vous ou créez un compte gratuit pour continuer." + "pleaseSignIn": "Connectez-vous ou créez un compte gratuit pour continuer.", + "loginWithPasskey": "Face ID / Empreinte", + "orOtherMethod": "ou autre méthode", + "usePassword": "Mot de passe", + "checkingPasskey": "Vérification...", + "passkeyError": "Erreur d'authentification Passkey", + "passwordlessSignup": "Inscription sans mot de passe !", + "passkeyAfterSignup": "Inscrivez-vous avec Google ou Facebook, puis configurez un passkey pour une connexion rapide.", + "signupWithGoogle": "S'inscrire avec Google", + "signupWithFacebook": "S'inscrire avec Facebook", + "setupPasskey": "Configurer Face ID / Empreinte", + "setupPasskeyPrompt": "Utilisez Face ID ou votre empreinte pour une connexion plus rapide.", + "setupPasskeyNow": "Configurer maintenant", + "skipForNow": "Plus tard", + "passkeySetupSuccess": "Passkey configuré avec succès !", + "passkeySetupError": "Erreur lors de la configuration du passkey", + "usernameRequired": "Nom d'utilisateur requis" }, "filters": { "all": "Tout", @@ -318,7 +347,10 @@ "typeMessage": "Tapez un message...", "online": "En ligne", "offline": "Hors ligne", - "typing": "en train d'écrire..." + "typing": "en train d'écrire...", + "sending": "Envoi...", + "sent": "Envoyé", + "read": "Lu" }, "postModal": { "readArticle": "Lire l'article complet",