feat(city-map): Add 2D City Map with coordinates and agent presence
- Add migration 013_city_map_coordinates.sql with map coordinates, zones, and agents table - Add /city/map API endpoint in city-service - Add /city/agents and /city/agents/online endpoints - Extend presence aggregator to include agents[] in snapshot - Add AgentsSource for fetching agent data from DB - Create CityMap component with interactive room tiles - Add useCityMap hook for fetching map data - Update useGlobalPresence to include agents - Add map/list view toggle on /city page - Add agent badges to room cards and map tiles
This commit is contained in:
@@ -358,3 +358,4 @@ cat docs/tasks/PHASE3_MASTER_TASK.md | pbcopy
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,3 +125,4 @@ if (errorMessage.includes('Provider error') ||
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -611,3 +611,4 @@ await knowledgeBaseService.uploadFile("helion", file);
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -184,3 +184,4 @@ Request body: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,3 +125,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -421,3 +421,4 @@ http://localhost:8899/microdao/daarion
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -514,3 +514,4 @@ const systemPrompt = DEFAULT_PROMPTS[agentId][language];
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -642,3 +642,4 @@ GET /api/telegram/{agent_id}/status
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -161,3 +161,4 @@ INFO: Selected provider: LLMProvider(id='llm_local_qwen3_8b')
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -460,3 +460,4 @@ Remaining Work:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,3 +130,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -170,3 +170,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -307,3 +307,4 @@ export function EnergyUnionCabinetPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -148,3 +148,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -381,3 +381,4 @@ Helion потребує перереєстрації webhook, інші боти
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -499,3 +499,4 @@ export function MobileResponsiveChatPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -172,3 +172,4 @@ LLM сервіси повністю налаштовані та працюють
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -365,3 +365,4 @@ http://localhost:8899/microdao/energy-union
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -105,3 +105,4 @@ getMicroDaoWorkspace(microDaoId: string): Promise<Workspace | null>
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -411,3 +411,4 @@ curl http://localhost:9500/api/agent/monitor/file-urls?agent_id=monitor
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -162,3 +162,4 @@ curl -X POST http://localhost:9500/api/agent/monitor/chat \
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -316,3 +316,4 @@ curl 'http://localhost:9500/api/project/changes?since=1700000000000&limit=10'
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -246,3 +246,4 @@ location.reload();
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -176,3 +176,4 @@ window.dispatchEvent(new CustomEvent('project-change', {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -367,3 +367,4 @@ localStorage.setItem(storageKey, JSON.stringify(changes.slice(0, 50)));
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -183,3 +183,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -536,3 +536,4 @@ curl -X POST http://localhost:8896/api/ocr/upload \
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -221,3 +221,4 @@ docker exec ollama ollama ps
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,3 +72,4 @@ echo " 4. Протестувати Ollama з GPU: ollama run qwen3:8b 'test'"
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -110,3 +110,4 @@ time ollama run qwen3:8b "Привіт, тест GPU"
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,3 +107,4 @@ time ollama run qwen3:8b "test"
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,4 @@ echo " - Загальне CPU: 85.3% → 40-50%"
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -627,3 +627,4 @@ const saveConversation = async () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -445,3 +445,4 @@ You now have a fully functional agent integration system. Agents can automatical
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -380,3 +380,4 @@ Test 5: Internal Endpoints
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -378,3 +378,4 @@ All specifications are complete. Pick a starting point:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -471,3 +471,4 @@ docker-compose -f docker-compose.phase3.yml down -v
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -401,3 +401,4 @@ Sofia: "В проєкті X є 3 нові задачі:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -430,3 +430,4 @@ Complete Phase 4.5 fully (2-3 години) → Then start Phase 5 with real aut
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -522,3 +522,4 @@ useAuthStore.getState().clearSession();
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -322,3 +322,4 @@ PHASE4_PROGRESS_REPORT.md ✅ (this file)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -571,3 +571,4 @@ Code Quality:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -193,3 +193,4 @@ curl http://localhost:7011/auth/me \
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -196,3 +196,4 @@ curl -X POST http://localhost:7011/auth/login \
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -153,3 +153,4 @@ If agent replies, **Phase 2 works!** 🚀
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -247,3 +247,4 @@ After Phase 3 works:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -321,3 +321,4 @@ curl http://144.76.224.179:9102/health
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -410,3 +410,4 @@ cat docs/tasks/PHASE2_MASTER_TASK.md | pbcopy
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -338,3 +338,4 @@ cd services/agent-filter
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -192,3 +192,4 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:7014';
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -184,3 +184,4 @@ docker-compose restart swapper-service
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -200,3 +200,4 @@ swapper:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -259,3 +259,4 @@ swapper:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -196,3 +196,4 @@ ssh root@144.76.224.179 "cd /opt/microdao-daarion && docker-compose up -d swappe
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -313,3 +313,4 @@ cryptography==41.0.7
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -367,3 +367,4 @@ done
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -601,3 +601,4 @@ async def universal_telegram_webhook(bot_id: str, update: TelegramUpdate):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -128,3 +128,4 @@ docker logs --tail 50 dagi-web-search-service
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -167,3 +167,4 @@ INFO: 145.224.94.89:27620 - "POST /route HTTP/1.1" 200 OK
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -321,3 +321,4 @@ docker ps | grep -E 'dagi-gateway|dagi-tts|dagi-stt'
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -300,3 +300,4 @@ async def text_to_speech(text: str, voice_id: str):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,74 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Mail, Lock, Loader2, Sparkles, AlertCircle } from 'lucide-react'
|
import { Mail, Lock, Loader2, Sparkles, AlertCircle } from 'lucide-react'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { login as authLogin } from '@/lib/auth'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { login, isAuthenticated } = useAuth()
|
const { refreshUser, isAuthenticated } = useAuth()
|
||||||
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated (client-side effect to avoid rendering push)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router])
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
router.push('/')
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateForm = (): string | null => {
|
const validateForm = (currentEmail: string, currentPassword: string): string | null => {
|
||||||
if (!email.trim()) {
|
if (!currentEmail.trim()) {
|
||||||
return 'Введіть email адресу'
|
return 'Введіть email адресу'
|
||||||
}
|
}
|
||||||
// Basic email validation
|
// Basic email validation
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
if (!emailRegex.test(email)) {
|
if (!emailRegex.test(currentEmail)) {
|
||||||
return 'Введіть коректну email адресу'
|
return 'Введіть коректну email адресу'
|
||||||
}
|
}
|
||||||
if (!password) {
|
if (!currentPassword) {
|
||||||
return 'Введіть пароль'
|
return 'Введіть пароль'
|
||||||
}
|
}
|
||||||
if (password.length < 8) {
|
if (currentPassword.length < 8) {
|
||||||
return 'Пароль повинен містити мінімум 8 символів'
|
return 'Пароль повинен містити мінімум 8 символів'
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
// Client-side validation
|
const formData = new FormData(e.currentTarget)
|
||||||
const validationError = validateForm()
|
const fallbackEmail = (formData.get('email')?.toString() ?? '').trim()
|
||||||
|
const fallbackPassword = formData.get('password')?.toString() ?? ''
|
||||||
|
const currentEmail = email.trim() || fallbackEmail
|
||||||
|
const currentPassword = password || fallbackPassword
|
||||||
|
|
||||||
|
if (!email && currentEmail) setEmail(currentEmail)
|
||||||
|
if (!password && currentPassword) setPassword(currentPassword)
|
||||||
|
|
||||||
|
const validationError = validateForm(currentEmail, currentPassword)
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
setError(validationError)
|
setError(validationError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email, password)
|
await authLogin(currentEmail, currentPassword)
|
||||||
|
await refreshUser()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Помилка входу. Перевірте дані та спробуйте ще раз.')
|
setError(err instanceof Error ? err.message : 'Помилка входу. Перевірте дані та спробуйте ще раз.')
|
||||||
@@ -100,6 +114,7 @@ export default function LoginPage() {
|
|||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
@@ -124,6 +139,7 @@ export default function LoginPage() {
|
|||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -157,6 +173,7 @@ export default function LoginPage() {
|
|||||||
'Увійти'
|
'Увійти'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import Link from 'next/link'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Mail, Lock, User, Loader2, Sparkles, AlertCircle, CheckCircle2 } from 'lucide-react'
|
import { Mail, Lock, User, Loader2, Sparkles, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { register as authRegister, login as authLogin } from '@/lib/auth'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { register, isAuthenticated } = useAuth()
|
const { refreshUser, isAuthenticated } = useAuth()
|
||||||
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -34,18 +35,22 @@ export default function RegisterPage() {
|
|||||||
const isPasswordValid = passwordRequirements.every(r => r.met)
|
const isPasswordValid = passwordRequirements.every(r => r.met)
|
||||||
const doPasswordsMatch = password === confirmPassword && password.length > 0
|
const doPasswordsMatch = password === confirmPassword && password.length > 0
|
||||||
|
|
||||||
const validateForm = (): string | null => {
|
const validateForm = (
|
||||||
if (!email.trim()) {
|
currentEmail: string,
|
||||||
|
currentPassword: string,
|
||||||
|
confirm: string
|
||||||
|
): string | null => {
|
||||||
|
if (!currentEmail.trim()) {
|
||||||
return 'Введіть email адресу'
|
return 'Введіть email адресу'
|
||||||
}
|
}
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
if (!emailRegex.test(email)) {
|
if (!emailRegex.test(currentEmail)) {
|
||||||
return 'Введіть коректну email адресу'
|
return 'Введіть коректну email адресу'
|
||||||
}
|
}
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
return 'Пароль не відповідає вимогам'
|
return 'Пароль не відповідає вимогам'
|
||||||
}
|
}
|
||||||
if (!doPasswordsMatch) {
|
if (currentPassword !== confirm || currentPassword.length === 0) {
|
||||||
return 'Паролі не співпадають'
|
return 'Паролі не співпадають'
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -55,7 +60,23 @@ export default function RegisterPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
const validationError = validateForm()
|
const formData = new FormData(e.currentTarget as HTMLFormElement)
|
||||||
|
const fallbackEmail = (formData.get('email')?.toString() ?? '').trim()
|
||||||
|
const fallbackPassword = formData.get('password')?.toString() ?? ''
|
||||||
|
const fallbackConfirm = formData.get('confirmPassword')?.toString() ?? ''
|
||||||
|
const fallbackDisplayName = formData.get('displayName')?.toString() ?? ''
|
||||||
|
|
||||||
|
const currentEmail = email.trim() || fallbackEmail
|
||||||
|
const currentPassword = password || fallbackPassword
|
||||||
|
const currentConfirm = confirmPassword || fallbackConfirm
|
||||||
|
const currentDisplayName = displayName || fallbackDisplayName
|
||||||
|
|
||||||
|
if (!email && currentEmail) setEmail(currentEmail)
|
||||||
|
if (!password && currentPassword) setPassword(currentPassword)
|
||||||
|
if (!confirmPassword && currentConfirm) setConfirmPassword(currentConfirm)
|
||||||
|
if (!displayName && currentDisplayName) setDisplayName(currentDisplayName)
|
||||||
|
|
||||||
|
const validationError = validateForm(currentEmail, currentPassword, currentConfirm)
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
setError(validationError)
|
setError(validationError)
|
||||||
return
|
return
|
||||||
@@ -64,7 +85,9 @@ export default function RegisterPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register(email, password, displayName || undefined)
|
await authRegister(currentEmail, currentPassword, currentDisplayName || undefined)
|
||||||
|
await authLogin(currentEmail, currentPassword)
|
||||||
|
await refreshUser()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Помилка реєстрації. Спробуйте ще раз.')
|
setError(err instanceof Error ? err.message : 'Помилка реєстрації. Спробуйте ще раз.')
|
||||||
@@ -110,6 +133,7 @@ export default function RegisterPage() {
|
|||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
id="displayName"
|
id="displayName"
|
||||||
|
name="displayName"
|
||||||
type="text"
|
type="text"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
@@ -134,6 +158,7 @@ export default function RegisterPage() {
|
|||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
@@ -158,6 +183,7 @@ export default function RegisterPage() {
|
|||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -197,6 +223,7 @@ export default function RegisterPage() {
|
|||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
|||||||
25
apps/web/src/app/api/auth/login/route.ts
Normal file
25
apps/web/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.text()
|
||||||
|
const response = await fetch(`${INTERNAL_API_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': req.headers.get('content-type') || 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
|
||||||
|
return new Response(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
25
apps/web/src/app/api/auth/logout/route.ts
Normal file
25
apps/web/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.text()
|
||||||
|
const response = await fetch(`${INTERNAL_API_URL}/api/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': req.headers.get('content-type') || 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
|
||||||
|
return new Response(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
23
apps/web/src/app/api/auth/me/route.ts
Normal file
23
apps/web/src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const response = await fetch(`${INTERNAL_API_URL}/api/auth/me`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: req.headers.get('authorization') || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
|
||||||
|
return new Response(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
25
apps/web/src/app/api/auth/refresh/route.ts
Normal file
25
apps/web/src/app/api/auth/refresh/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.text()
|
||||||
|
const response = await fetch(`${INTERNAL_API_URL}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': req.headers.get('content-type') || 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
|
||||||
|
return new Response(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
25
apps/web/src/app/api/auth/register/route.ts
Normal file
25
apps/web/src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.text()
|
||||||
|
const response = await fetch(`${INTERNAL_API_URL}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': req.headers.get('content-type') || 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
|
||||||
|
return new Response(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
35
apps/web/src/app/api/city/map/route.ts
Normal file
35
apps/web/src/app/api/city/map/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || "http://localhost:7001";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${CITY_SERVICE_URL}/city/map`, {
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
console.error(`City map API error: ${upstream.status}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch city map", status: upstream.status },
|
||||||
|
{ status: upstream.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await upstream.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("City map proxy error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "City service unavailable" },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,11 +3,11 @@ import { NextRequest } from "next/server";
|
|||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const PRESENCE_AGGREGATOR_URL = process.env.PRESENCE_AGGREGATOR_URL || "http://localhost:8085/presence/stream";
|
const PRESENCE_AGGREGATOR_URL = process.env.PRESENCE_AGGREGATOR_URL || "http://localhost:8085";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(PRESENCE_AGGREGATOR_URL, {
|
const upstream = await fetch(`${PRESENCE_AGGREGATOR_URL}/presence/stream`, {
|
||||||
headers: {
|
headers: {
|
||||||
accept: "text/event-stream",
|
accept: "text/event-stream",
|
||||||
},
|
},
|
||||||
@@ -62,3 +62,4 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
34
apps/web/src/app/api/presence/summary/route.ts
Normal file
34
apps/web/src/app/api/presence/summary/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const PRESENCE_AGGREGATOR_URL = process.env.PRESENCE_AGGREGATOR_URL || "http://localhost:8085";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${PRESENCE_AGGREGATOR_URL}/presence/summary`, {
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to connect to presence aggregator", status: upstream.status },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await upstream.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Presence summary proxy error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Presence aggregator unavailable" },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Building2, Users, Star, MessageSquare, ArrowRight, Loader2 } from 'lucide-react'
|
import { Building2, Users, Star, MessageSquare, ArrowRight, Loader2, Bot, Map } from 'lucide-react'
|
||||||
import { api, CityRoom } from '@/lib/api'
|
import { api, CityRoom } from '@/lib/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useGlobalPresence } from '@/hooks/useGlobalPresence'
|
import { useGlobalPresence } from '@/hooks/useGlobalPresence'
|
||||||
|
import { CityMap } from '@/components/city/CityMap'
|
||||||
|
|
||||||
export default function CityPage() {
|
export default function CityPage() {
|
||||||
const [rooms, setRooms] = useState<CityRoom[]>([])
|
const [rooms, setRooms] = useState<CityRoom[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { cityOnline, roomsPresence } = useGlobalPresence()
|
const [showMap, setShowMap] = useState(true)
|
||||||
|
const { cityOnline, roomsPresence, agents } = useGlobalPresence()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchRooms() {
|
async function fetchRooms() {
|
||||||
@@ -68,27 +70,65 @@ export default function CityPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMap(true)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all",
|
||||||
|
showMap
|
||||||
|
? "bg-cyan-500/20 text-cyan-400 border border-cyan-500/30"
|
||||||
|
: "text-slate-400 hover:text-white hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Map className="w-4 h-4" />
|
||||||
|
Мапа
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMap(false)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all",
|
||||||
|
!showMap
|
||||||
|
? "bg-cyan-500/20 text-cyan-400 border border-cyan-500/30"
|
||||||
|
: "text-slate-400 hover:text-white hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
Список
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City Map View */}
|
||||||
|
{showMap && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<CityMap />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rooms Grid */}
|
{/* Rooms Grid */}
|
||||||
{rooms.length === 0 ? (
|
{!showMap && (
|
||||||
<div className="glass-panel p-12 text-center">
|
rooms.length === 0 ? (
|
||||||
<Building2 className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
<div className="glass-panel p-12 text-center">
|
||||||
<h2 className="text-xl font-semibold text-white mb-2">
|
<Building2 className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
||||||
Кімнати не знайдено
|
<h2 className="text-xl font-semibold text-white mb-2">
|
||||||
</h2>
|
Кімнати не знайдено
|
||||||
<p className="text-slate-400">
|
</h2>
|
||||||
API недоступний або кімнати ще не створені
|
<p className="text-slate-400">
|
||||||
</p>
|
API недоступний або кімнати ще не створені
|
||||||
</div>
|
</p>
|
||||||
) : (
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
) : (
|
||||||
{rooms.map((room) => (
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
<RoomCard
|
{rooms.map((room) => (
|
||||||
key={room.id}
|
<RoomCard
|
||||||
room={room}
|
key={room.id}
|
||||||
livePresence={roomsPresence[room.id]}
|
room={room}
|
||||||
/>
|
livePresence={roomsPresence[room.id]}
|
||||||
))}
|
roomAgents={agents.filter(a => a.room_id === room.id)}
|
||||||
</div>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Section */}
|
{/* Stats Section */}
|
||||||
@@ -126,9 +166,10 @@ export default function CityPage() {
|
|||||||
interface RoomCardProps {
|
interface RoomCardProps {
|
||||||
room: CityRoom
|
room: CityRoom
|
||||||
livePresence?: { online: number; typing: number }
|
livePresence?: { online: number; typing: number }
|
||||||
|
roomAgents?: Array<{ agent_id: string; display_name: string; status: string; color?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoomCard({ room, livePresence }: RoomCardProps) {
|
function RoomCard({ room, livePresence, roomAgents = [] }: RoomCardProps) {
|
||||||
// Use live presence if available, otherwise fallback to API data
|
// Use live presence if available, otherwise fallback to API data
|
||||||
const onlineCount = livePresence?.online ?? room.members_online
|
const onlineCount = livePresence?.online ?? room.members_online
|
||||||
const typingCount = livePresence?.typing ?? 0
|
const typingCount = livePresence?.typing ?? 0
|
||||||
@@ -186,6 +227,34 @@ function RoomCard({ room, livePresence }: RoomCardProps) {
|
|||||||
|
|
||||||
<ArrowRight className="w-5 h-5 text-slate-500 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all" />
|
<ArrowRight className="w-5 h-5 text-slate-500 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Agents in room */}
|
||||||
|
{roomAgents.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-xs text-slate-400">Агенти:</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{roomAgents.slice(0, 3).map((agent) => (
|
||||||
|
<span
|
||||||
|
key={agent.agent_id}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 text-xs rounded-full",
|
||||||
|
agent.status === 'online'
|
||||||
|
? "bg-green-500/20 text-green-400"
|
||||||
|
: "bg-orange-500/20 text-orange-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{agent.display_name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{roomAgents.length > 3 && (
|
||||||
|
<span className="text-xs text-slate-500">+{roomAgents.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
250
apps/web/src/components/city/CityMap.tsx
Normal file
250
apps/web/src/components/city/CityMap.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useCityMap, CityMapRoom } from '@/hooks/useCityMap'
|
||||||
|
import { useGlobalPresence } from '@/hooks/useGlobalPresence'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
Zap,
|
||||||
|
FlaskConical,
|
||||||
|
Hammer,
|
||||||
|
HandMetal,
|
||||||
|
Users,
|
||||||
|
Bot,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { AgentPresence } from '@/lib/global-presence'
|
||||||
|
|
||||||
|
// Icon mapping
|
||||||
|
const iconMap: Record<string, React.ElementType> = {
|
||||||
|
'message-square': MessageSquare,
|
||||||
|
'zap': Zap,
|
||||||
|
'flask-conical': FlaskConical,
|
||||||
|
'hammer': Hammer,
|
||||||
|
'hand-wave': HandMetal,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color mapping to Tailwind classes
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
cyan: 'from-cyan-500/20 to-cyan-600/10 border-cyan-500/30 hover:border-cyan-400/50',
|
||||||
|
green: 'from-green-500/20 to-green-600/10 border-green-500/30 hover:border-green-400/50',
|
||||||
|
orange: 'from-orange-500/20 to-orange-600/10 border-orange-500/30 hover:border-orange-400/50',
|
||||||
|
purple: 'from-purple-500/20 to-purple-600/10 border-purple-500/30 hover:border-purple-400/50',
|
||||||
|
yellow: 'from-yellow-500/20 to-yellow-600/10 border-yellow-500/30 hover:border-yellow-400/50',
|
||||||
|
blue: 'from-blue-500/20 to-blue-600/10 border-blue-500/30 hover:border-blue-400/50',
|
||||||
|
}
|
||||||
|
|
||||||
|
const textColorMap: Record<string, string> = {
|
||||||
|
cyan: 'text-cyan-400',
|
||||||
|
green: 'text-green-400',
|
||||||
|
orange: 'text-orange-400',
|
||||||
|
purple: 'text-purple-400',
|
||||||
|
yellow: 'text-yellow-400',
|
||||||
|
blue: 'text-blue-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomTileProps {
|
||||||
|
room: CityMapRoom
|
||||||
|
online: number
|
||||||
|
typing: number
|
||||||
|
agents: AgentPresence[]
|
||||||
|
cellSize: number
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoomTile({ room, online, typing, agents, cellSize, onClick }: RoomTileProps) {
|
||||||
|
const Icon = iconMap[room.icon || 'message-square'] || MessageSquare
|
||||||
|
const colorClass = colorMap[room.color || 'cyan'] || colorMap.cyan
|
||||||
|
const textColor = textColorMap[room.color || 'cyan'] || textColorMap.cyan
|
||||||
|
|
||||||
|
// Calculate brightness based on online count
|
||||||
|
const brightness = Math.min(1, 0.3 + (online * 0.15))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'absolute rounded-xl border transition-all duration-300',
|
||||||
|
'bg-gradient-to-br backdrop-blur-sm',
|
||||||
|
'hover:scale-[1.02] hover:shadow-lg cursor-pointer',
|
||||||
|
'flex flex-col items-center justify-center gap-1 p-2',
|
||||||
|
colorClass
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: room.x * cellSize,
|
||||||
|
top: room.y * cellSize,
|
||||||
|
width: room.w * cellSize - 8,
|
||||||
|
height: room.h * cellSize - 8,
|
||||||
|
opacity: brightness,
|
||||||
|
}}
|
||||||
|
title={`${room.name} - ${online} online`}
|
||||||
|
>
|
||||||
|
<Icon className={cn('w-6 h-6', textColor)} />
|
||||||
|
<span className="text-xs font-medium text-white truncate max-w-full">
|
||||||
|
{room.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Online count */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="w-3 h-3 text-slate-400" />
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs font-bold',
|
||||||
|
online > 0 ? 'text-green-400' : 'text-slate-500'
|
||||||
|
)}>
|
||||||
|
{online}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Typing indicator */}
|
||||||
|
{typing > 0 && (
|
||||||
|
<span className="text-xs text-cyan-400 animate-pulse">...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent badges */}
|
||||||
|
{agents.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
{agents.slice(0, 3).map((agent) => (
|
||||||
|
<div
|
||||||
|
key={agent.agent_id}
|
||||||
|
className={cn(
|
||||||
|
'w-5 h-5 rounded-full flex items-center justify-center',
|
||||||
|
'bg-slate-800/80 border',
|
||||||
|
agent.status === 'online' ? 'border-green-500/50' : 'border-orange-500/50'
|
||||||
|
)}
|
||||||
|
title={`${agent.display_name} (${agent.status})`}
|
||||||
|
>
|
||||||
|
<Bot className={cn(
|
||||||
|
'w-3 h-3',
|
||||||
|
textColorMap[agent.color || 'cyan'] || 'text-cyan-400'
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{agents.length > 3 && (
|
||||||
|
<span className="text-xs text-slate-400">+{agents.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CityMap() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { config, rooms, loading, error } = useCityMap()
|
||||||
|
const { cityOnline, roomsPresence, agents } = useGlobalPresence()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="glass-panel p-8 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
|
||||||
|
<span className="ml-3 text-slate-400">Завантаження мапи...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="glass-panel p-8 text-center">
|
||||||
|
<p className="text-red-400">Помилка завантаження мапи: {error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config || rooms.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="glass-panel p-8 text-center">
|
||||||
|
<p className="text-slate-400">Мапа міста порожня</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellSize = config.cell_size
|
||||||
|
const mapWidth = config.grid_width * cellSize
|
||||||
|
const mapHeight = config.grid_height * cellSize
|
||||||
|
|
||||||
|
// Count online agents
|
||||||
|
const onlineAgents = agents.filter(a => a.status === 'online' || a.status === 'busy')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-panel p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Мапа Міста</h2>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-green-400 font-medium">{cityOnline}</span>
|
||||||
|
<span className="text-slate-400">онлайн</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Bot className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-cyan-400 font-medium">{onlineAgents.length}</span>
|
||||||
|
<span className="text-slate-400">агентів</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map container */}
|
||||||
|
<div
|
||||||
|
className="relative bg-slate-900/50 rounded-xl overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: mapWidth,
|
||||||
|
height: mapHeight,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Grid background */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-10"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: `${cellSize}px ${cellSize}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Room tiles */}
|
||||||
|
{rooms.map((room) => {
|
||||||
|
const presence = roomsPresence[room.id]
|
||||||
|
const roomAgents = agents.filter(a => a.room_id === room.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RoomTile
|
||||||
|
key={room.id}
|
||||||
|
room={room}
|
||||||
|
online={presence?.online || 0}
|
||||||
|
typing={presence?.typing || 0}
|
||||||
|
agents={roomAgents}
|
||||||
|
cellSize={cellSize}
|
||||||
|
onClick={() => router.push(`/city/${room.slug}`)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-gradient-to-br from-cyan-500/40 to-cyan-600/20" />
|
||||||
|
<span>Public</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-gradient-to-br from-green-500/40 to-green-600/20" />
|
||||||
|
<span>Social</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-gradient-to-br from-purple-500/40 to-purple-600/20" />
|
||||||
|
<span>Science</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-gradient-to-br from-orange-500/40 to-orange-600/20" />
|
||||||
|
<span>Builders</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
71
apps/web/src/hooks/useCityMap.ts
Normal file
71
apps/web/src/hooks/useCityMap.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
export interface CityMapRoom {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
room_type: string;
|
||||||
|
zone: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
matrix_room_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityMapConfig {
|
||||||
|
grid_width: number;
|
||||||
|
grid_height: number;
|
||||||
|
cell_size: number;
|
||||||
|
background_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityMapData {
|
||||||
|
config: CityMapConfig;
|
||||||
|
rooms: CityMapRoom[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCityMap() {
|
||||||
|
const [data, setData] = useState<CityMapData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchMap = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/city/map");
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch city map: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapData: CityMapData = await res.json();
|
||||||
|
setData(mapData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching city map:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMap();
|
||||||
|
}, [fetchMap]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: data?.config ?? null,
|
||||||
|
rooms: data?.rooms ?? [],
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { globalPresenceClient, RoomPresence } from '@/lib/global-presence'
|
import { globalPresenceClient, RoomPresence, AgentPresence } from '@/lib/global-presence'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for subscribing to global room presence updates via SSE
|
* Hook for subscribing to global room presence updates via SSE
|
||||||
@@ -9,17 +9,19 @@ import { globalPresenceClient, RoomPresence } from '@/lib/global-presence'
|
|||||||
export function useGlobalPresence() {
|
export function useGlobalPresence() {
|
||||||
const [cityOnline, setCityOnline] = useState(0)
|
const [cityOnline, setCityOnline] = useState(0)
|
||||||
const [roomsPresence, setRoomsPresence] = useState<Record<string, RoomPresence>>({})
|
const [roomsPresence, setRoomsPresence] = useState<Record<string, RoomPresence>>({})
|
||||||
|
const [agents, setAgents] = useState<AgentPresence[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = globalPresenceClient.subscribe((newCityOnline, newRoomsPresence) => {
|
const unsubscribe = globalPresenceClient.subscribe((newCityOnline, newRoomsPresence, newAgents) => {
|
||||||
setCityOnline(newCityOnline)
|
setCityOnline(newCityOnline)
|
||||||
setRoomsPresence(newRoomsPresence)
|
setRoomsPresence(newRoomsPresence)
|
||||||
|
setAgents(newAgents)
|
||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { cityOnline, roomsPresence }
|
return { cityOnline, roomsPresence, agents }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,3 +32,11 @@ export function useRoomPresence(roomId: string): RoomPresence | null {
|
|||||||
return roomsPresence[roomId] || null
|
return roomsPresence[roomId] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for getting agents in a specific room
|
||||||
|
*/
|
||||||
|
export function useRoomAgents(roomId: string): AgentPresence[] {
|
||||||
|
const { agents } = useGlobalPresence()
|
||||||
|
return agents.filter(a => a.room_id === roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,3 +148,4 @@ export function usePresenceHeartbeat({
|
|||||||
}, [enabled, matrixUserId, accessToken, sendPresence])
|
}, [enabled, matrixUserId, accessToken, sendPresence])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -106,8 +106,15 @@ export async function login(email: string, password: string): Promise<LoginRespo
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
let errorDetail = 'Login failed'
|
||||||
throw new Error(error.detail || 'Login failed')
|
try {
|
||||||
|
const error = await response.json()
|
||||||
|
errorDetail = error.detail || error.message || errorDetail
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorDetail)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: LoginResponse = await response.json()
|
const data: LoginResponse = await response.json()
|
||||||
|
|||||||
@@ -4,16 +4,27 @@
|
|||||||
* Connects to /api/presence/stream for real-time room presence updates via SSE
|
* Connects to /api/presence/stream for real-time room presence updates via SSE
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface AgentPresence {
|
||||||
|
agent_id: string;
|
||||||
|
display_name: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
room_id?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoomPresence {
|
export interface RoomPresence {
|
||||||
room_id: string;
|
room_id: string;
|
||||||
matrix_room_id?: string;
|
matrix_room_id?: string;
|
||||||
online: number;
|
online: number;
|
||||||
typing: number;
|
typing: number;
|
||||||
|
agents?: AgentPresence[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CityPresence {
|
export interface CityPresence {
|
||||||
online_total: number;
|
online_total: number;
|
||||||
rooms_online: number;
|
rooms_online: number;
|
||||||
|
agents_online?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PresenceEvent {
|
export interface PresenceEvent {
|
||||||
@@ -21,17 +32,21 @@ export interface PresenceEvent {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
city: CityPresence;
|
city: CityPresence;
|
||||||
rooms: RoomPresence[];
|
rooms: RoomPresence[];
|
||||||
|
agents?: AgentPresence[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PresenceCallback = (
|
export type PresenceCallback = (
|
||||||
cityOnline: number,
|
cityOnline: number,
|
||||||
roomsPresence: Record<string, RoomPresence>
|
roomsPresence: Record<string, RoomPresence>,
|
||||||
|
agents: AgentPresence[]
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
class GlobalPresenceClient {
|
class GlobalPresenceClient {
|
||||||
private eventSource: EventSource | null = null;
|
private eventSource: EventSource | null = null;
|
||||||
private cityOnline: number = 0;
|
private cityOnline: number = 0;
|
||||||
|
private agentsOnline: number = 0;
|
||||||
private roomsPresence: Record<string, RoomPresence> = {};
|
private roomsPresence: Record<string, RoomPresence> = {};
|
||||||
|
private agents: AgentPresence[] = [];
|
||||||
private listeners: Set<PresenceCallback> = new Set();
|
private listeners: Set<PresenceCallback> = new Set();
|
||||||
private reconnectTimeout: NodeJS.Timeout | null = null;
|
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
private isConnecting = false;
|
private isConnecting = false;
|
||||||
@@ -99,7 +114,7 @@ class GlobalPresenceClient {
|
|||||||
|
|
||||||
// Send current state immediately
|
// Send current state immediately
|
||||||
if (this.cityOnline > 0 || Object.keys(this.roomsPresence).length > 0) {
|
if (this.cityOnline > 0 || Object.keys(this.roomsPresence).length > 0) {
|
||||||
callback(this.cityOnline, this.roomsPresence);
|
callback(this.cityOnline, this.roomsPresence, this.agents);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect if not connected
|
// Connect if not connected
|
||||||
@@ -115,6 +130,18 @@ class GlobalPresenceClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAgentsOnline(): number {
|
||||||
|
return this.agentsOnline;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllAgents(): AgentPresence[] {
|
||||||
|
return [...this.agents];
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentsInRoom(roomId: string): AgentPresence[] {
|
||||||
|
return this.agents.filter(a => a.room_id === roomId);
|
||||||
|
}
|
||||||
|
|
||||||
getCityOnline(): number {
|
getCityOnline(): number {
|
||||||
return this.cityOnline;
|
return this.cityOnline;
|
||||||
}
|
}
|
||||||
@@ -132,6 +159,7 @@ class GlobalPresenceClient {
|
|||||||
|
|
||||||
// Update city stats
|
// Update city stats
|
||||||
this.cityOnline = data.city?.online_total || 0;
|
this.cityOnline = data.city?.online_total || 0;
|
||||||
|
this.agentsOnline = data.city?.agents_online || 0;
|
||||||
|
|
||||||
// Update rooms
|
// Update rooms
|
||||||
const newRoomsPresence: Record<string, RoomPresence> = {};
|
const newRoomsPresence: Record<string, RoomPresence> = {};
|
||||||
@@ -140,13 +168,16 @@ class GlobalPresenceClient {
|
|||||||
}
|
}
|
||||||
this.roomsPresence = newRoomsPresence;
|
this.roomsPresence = newRoomsPresence;
|
||||||
|
|
||||||
|
// Update agents
|
||||||
|
this.agents = data.agents || [];
|
||||||
|
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyListeners(): void {
|
private notifyListeners(): void {
|
||||||
for (const callback of this.listeners) {
|
for (const callback of this.listeners) {
|
||||||
try {
|
try {
|
||||||
callback(this.cityOnline, this.roomsPresence);
|
callback(this.cityOnline, this.roomsPresence, this.agents);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[GlobalPresence] Listener error:', e);
|
console.error('[GlobalPresence] Listener error:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,3 +77,4 @@ networks:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -84,3 +84,4 @@ networks:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -121,3 +121,4 @@ networks:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -186,3 +186,4 @@ volumes:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
101
docs/AUTH_SERVICE_FIX.md
Normal file
101
docs/AUTH_SERVICE_FIX.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# AUTH_SERVICE_FIX
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The auth-service (FastAPI + asyncpg) is responsible for registration, login, JWT
|
||||||
|
issuance and token introspection for the entire DAARION stack. A 500 error was
|
||||||
|
triggered because the configured Postgres database (`postgresql://.../daarion`)
|
||||||
|
did not exist on NODE1, so every `/api/auth/login` call failed with
|
||||||
|
`asyncpg.exceptions.InvalidCatalogNameError`. The fix introduced:
|
||||||
|
|
||||||
|
- creation of the `daarion` database inside `dagi-postgres`;
|
||||||
|
- execution of migration `011_create_auth_tables.sql` to provision the schema;
|
||||||
|
- addition of admin/test accounts via `/api/auth/register`;
|
||||||
|
- resilient configuration that supports both `AUTH_*` and legacy env names;
|
||||||
|
- smoke-tested register/login/refresh/me flows.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Name(s) | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `AUTH_DATABASE_URL` / `DATABASE_URL` | Postgres DSN (`postgresql://postgres:postgres@dagi-postgres:5432/daarion`) |
|
||||||
|
| `AUTH_JWT_SECRET` / `JWT_SECRET` | HMAC secret for both access & refresh tokens |
|
||||||
|
| `AUTH_JWT_ALGORITHM` / `JWT_ALGO` / `JWT_ALGORITHM` | JWT signing algorithm (`HS256`) |
|
||||||
|
| `AUTH_ACCESS_TOKEN_TTL` / `ACCESS_TOKEN_TTL` | Access token lifetime in seconds (default 1800) |
|
||||||
|
| `AUTH_REFRESH_TOKEN_TTL` / `REFRESH_TOKEN_TTL` | Refresh token lifetime in seconds (default 604800) |
|
||||||
|
| `AUTH_PORT` / `PORT` | Service port (default `7020`) |
|
||||||
|
| `AUTH_DEBUG` / `DEBUG` | Toggle FastAPI reload/logging |
|
||||||
|
| `AUTH_BCRYPT_ROUNDS` / `BCRYPT_ROUNDS` | Cost factor for password hashing |
|
||||||
|
| `SYNAPSE_ADMIN_URL` | Matrix admin endpoint (defaults to `http://daarion-synapse:8008`) |
|
||||||
|
| `SYNAPSE_REGISTRATION_SECRET` | Shared secret for Matrix auto-provisioning |
|
||||||
|
|
||||||
|
⚠️ The config module now checks both `AUTH_*` and legacy names so existing
|
||||||
|
docker-compose files continue to work.
|
||||||
|
|
||||||
|
## Database schema (minimal)
|
||||||
|
|
||||||
|
`migrations/011_create_auth_tables.sql` must be applied to the `daarion`
|
||||||
|
database. Core tables:
|
||||||
|
|
||||||
|
- `auth_users` — user profile + status flags (`is_active`, `is_admin`).
|
||||||
|
- `auth_roles` + `auth_user_roles` — role definitions/mapping (default roles
|
||||||
|
inserted by migration).
|
||||||
|
- `auth_sessions` — refresh-token sessions (with `expires_at` & `revoked_at`).
|
||||||
|
|
||||||
|
Commands executed on NODE1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec dagi-postgres psql -U postgres -c "CREATE DATABASE daarion;"
|
||||||
|
docker cp migrations/011_create_auth_tables.sql dagi-postgres:/tmp/011.sql
|
||||||
|
docker exec dagi-postgres psql -U postgres -d daarion -f /tmp/011.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/healthz` | Returns `{ "status": "ok" }` when DB + settings are valid |
|
||||||
|
| `POST` | `/api/auth/register` | Creates a user, hashes password, provisions Matrix user (`matrix_user_id` in response) |
|
||||||
|
| `POST` | `/api/auth/login` | Issues `access_token`, `refresh_token`, returns user payload + roles |
|
||||||
|
| `POST` | `/api/auth/refresh` | Validates refresh token/session and rotates tokens |
|
||||||
|
| `POST` | `/api/auth/logout` | Revokes refresh token/session |
|
||||||
|
| `GET` | `/api/auth/me` | Reads user profile using `Authorization: Bearer <access_token>` |
|
||||||
|
| `POST` | `/api/auth/introspect` | Validates any access token (for internal services) |
|
||||||
|
|
||||||
|
## JWT token
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "e4ea9638-a845-49b8-bd84-41deb3971ee0",
|
||||||
|
"email": "admin@daarion.space",
|
||||||
|
"name": "Admin",
|
||||||
|
"roles": ["user", "admin"],
|
||||||
|
"type": "access",
|
||||||
|
"iss": "daarion-auth",
|
||||||
|
"exp": 1764244050
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Gateway & frontend:
|
||||||
|
|
||||||
|
- Pass `Authorization: Bearer <access_token>` to protected endpoints.
|
||||||
|
- Extract `sub` as `user_id`, `roles` for RBAC, and (optionally) fetch
|
||||||
|
`matrix_user_id` from `/api/auth/register` response or the user profile.
|
||||||
|
|
||||||
|
## Smoke test flow
|
||||||
|
|
||||||
|
1. **Register:**
|
||||||
|
`curl -X POST http://<auth-host>:7020/api/auth/register -d '{"email":"user@daarion.space","password":"Password123!","display_name":"User"}'`
|
||||||
|
2. **Login:**
|
||||||
|
`curl -X POST http://<auth-host>:7020/api/auth/login -d '{"email":"user@daarion.space","password":"Password123!"}'`
|
||||||
|
3. **Authorize requests:**
|
||||||
|
`curl http://<auth-host>:7020/api/auth/me -H "Authorization: Bearer <access_token>"`
|
||||||
|
4. **Matrix heartbeat:**
|
||||||
|
After login in the web UI, `usePresenceHeartbeat` calls
|
||||||
|
`/api/internal/matrix/presence/online` with the issued token, and
|
||||||
|
`matrix-presence-aggregator` sees non-zero online counts.
|
||||||
|
|
||||||
|
With these fixes the auth-service is stable, compatible with matrix-gateway, and
|
||||||
|
ready for the next milestone (2D City Map + Agent Presence).
|
||||||
|
|
||||||
|
|
||||||
@@ -995,3 +995,4 @@ rules:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -486,3 +486,4 @@ curl -X POST http://localhost:8080/api/messaging/channels \
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -510,3 +510,4 @@ Instead of direct Matrix API:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -406,3 +406,4 @@ VALUES (gen_random_uuid(), '<channel-id>', 'agent:sofia', 'agent', '@sofia-agent
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -604,3 +604,4 @@ docker exec daarion-postgres psql -U postgres -d daarion \
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -189,3 +189,4 @@ Ref: messages.matrix_event_id - matrix_events.event_id [note: 'Message ↔ Matri
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -540,3 +540,4 @@ open http://localhost:8899/agents
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -862,3 +862,4 @@ networks:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -501,3 +501,4 @@ tools:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -274,3 +274,4 @@ Behavior:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -419,3 +419,4 @@ Behavior:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -237,3 +237,4 @@ COMMENT ON SCHEMA public IS 'Messenger schema v1 - Matrix-aware implementation';
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -153,3 +153,4 @@ ON CONFLICT (id) DO NOTHING;
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -144,3 +144,4 @@ EXECUTE FUNCTION update_timestamp();
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
134
migrations/013_city_map_coordinates.sql
Normal file
134
migrations/013_city_map_coordinates.sql
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
-- Migration 013: City Map Coordinates
|
||||||
|
-- 2D City Map - add spatial coordinates and visual properties to city_rooms
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Add map coordinates and visual properties
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE city_rooms
|
||||||
|
ADD COLUMN IF NOT EXISTS map_x INTEGER DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS map_y INTEGER DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS map_w INTEGER DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS map_h INTEGER DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS room_type TEXT DEFAULT 'public',
|
||||||
|
ADD COLUMN IF NOT EXISTS zone TEXT DEFAULT 'central',
|
||||||
|
ADD COLUMN IF NOT EXISTS icon TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS color TEXT;
|
||||||
|
|
||||||
|
-- Create index for spatial queries
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_city_rooms_map_coords ON city_rooms(map_x, map_y);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_city_rooms_zone ON city_rooms(zone);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_city_rooms_type ON city_rooms(room_type);
|
||||||
|
|
||||||
|
-- Add comments
|
||||||
|
COMMENT ON COLUMN city_rooms.map_x IS 'X coordinate on 2D city map (grid units)';
|
||||||
|
COMMENT ON COLUMN city_rooms.map_y IS 'Y coordinate on 2D city map (grid units)';
|
||||||
|
COMMENT ON COLUMN city_rooms.map_w IS 'Width on 2D city map (grid units)';
|
||||||
|
COMMENT ON COLUMN city_rooms.map_h IS 'Height on 2D city map (grid units)';
|
||||||
|
COMMENT ON COLUMN city_rooms.room_type IS 'Room type: public, governance, science, social, etc.';
|
||||||
|
COMMENT ON COLUMN city_rooms.zone IS 'City zone: central, north, south, east, west';
|
||||||
|
COMMENT ON COLUMN city_rooms.icon IS 'Icon identifier for UI (lucide icon name)';
|
||||||
|
COMMENT ON COLUMN city_rooms.color IS 'Primary color for room card/tile (hex or tailwind class)';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Update existing rooms with map coordinates
|
||||||
|
-- Layout: 5x3 grid (central area)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
UPDATE city_rooms SET
|
||||||
|
map_x = 2, map_y = 0, map_w = 2, map_h = 2,
|
||||||
|
room_type = 'public', zone = 'central',
|
||||||
|
icon = 'message-square', color = 'cyan'
|
||||||
|
WHERE id = 'room_city_general';
|
||||||
|
|
||||||
|
UPDATE city_rooms SET
|
||||||
|
map_x = 0, map_y = 0, map_w = 2, map_h = 1,
|
||||||
|
room_type = 'social', zone = 'west',
|
||||||
|
icon = 'hand-wave', color = 'green'
|
||||||
|
WHERE id = 'room_city_welcome';
|
||||||
|
|
||||||
|
UPDATE city_rooms SET
|
||||||
|
map_x = 4, map_y = 0, map_w = 2, map_h = 1,
|
||||||
|
room_type = 'builders', zone = 'east',
|
||||||
|
icon = 'hammer', color = 'orange'
|
||||||
|
WHERE id = 'room_city_builders';
|
||||||
|
|
||||||
|
UPDATE city_rooms SET
|
||||||
|
map_x = 0, map_y = 1, map_w = 2, map_h = 1,
|
||||||
|
room_type = 'science', zone = 'west',
|
||||||
|
icon = 'flask-conical', color = 'purple'
|
||||||
|
WHERE id = 'room_city_science';
|
||||||
|
|
||||||
|
UPDATE city_rooms SET
|
||||||
|
map_x = 4, map_y = 1, map_w = 2, map_h = 1,
|
||||||
|
room_type = 'energy', zone = 'east',
|
||||||
|
icon = 'zap', color = 'yellow'
|
||||||
|
WHERE id = 'room_city_energy';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- City Map Config (global settings)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS city_map_config (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT 'default',
|
||||||
|
grid_width INTEGER NOT NULL DEFAULT 6,
|
||||||
|
grid_height INTEGER NOT NULL DEFAULT 3,
|
||||||
|
cell_size INTEGER NOT NULL DEFAULT 100,
|
||||||
|
background_url TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO city_map_config (id, grid_width, grid_height, cell_size) VALUES
|
||||||
|
('default', 6, 3, 100)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
COMMENT ON TABLE city_map_config IS 'Global configuration for 2D city map rendering';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Agents table (for Agent Presence on map)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id TEXT PRIMARY KEY, -- ag_atlas, ag_oracle, etc.
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'assistant', -- assistant, civic, oracle, builder
|
||||||
|
avatar_url TEXT,
|
||||||
|
color TEXT DEFAULT 'cyan',
|
||||||
|
status TEXT DEFAULT 'offline', -- online, offline, busy
|
||||||
|
current_room_id TEXT REFERENCES city_rooms(id) ON DELETE SET NULL,
|
||||||
|
capabilities JSONB DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_status ON agents(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_room ON agents(current_room_id) WHERE current_room_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_kind ON agents(kind);
|
||||||
|
|
||||||
|
COMMENT ON TABLE agents IS 'AI Agents registry for DAARION City';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Seed default agents
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO agents (id, display_name, kind, color, status, current_room_id, capabilities) VALUES
|
||||||
|
('ag_atlas', 'Atlas', 'civic', 'cyan', 'online', 'room_city_general',
|
||||||
|
'["chat", "moderation", "onboarding"]'),
|
||||||
|
('ag_oracle', 'Oracle', 'oracle', 'purple', 'online', 'room_city_science',
|
||||||
|
'["research", "analysis", "predictions"]'),
|
||||||
|
('ag_builder', 'Builder Bot', 'builder', 'orange', 'offline', 'room_city_builders',
|
||||||
|
'["code", "automation", "integration"]'),
|
||||||
|
('ag_greeter', 'Greeter', 'social', 'green', 'online', 'room_city_welcome',
|
||||||
|
'["welcome", "help", "faq"]')
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
kind = EXCLUDED.kind,
|
||||||
|
color = EXCLUDED.color,
|
||||||
|
current_room_id = EXCLUDED.current_room_id,
|
||||||
|
capabilities = EXCLUDED.capabilities,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- END OF MIGRATION 013
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
@@ -78,3 +78,4 @@ http {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user