diff --git a/CONTINUE.md b/CONTINUE.md index a005550..bb3b77c 100644 --- a/CONTINUE.md +++ b/CONTINUE.md @@ -1,144 +1,141 @@ # Недоделки — начать отсюда +## Последнее исправление (28.02.2026) + +### Fix: auth-svc не собирался в Docker +- Добавлена строка сборки `auth-svc` в `Dockerfile.all` +- Удалён дубликат `ratelimit_tiered.go` (конфликт типов с Redis-версией) +- Добавлен REDIS_URL в api-gateway для rate limiting +- Пересоздан тестовый пользователь через API + +--- + ## Последнее изменение (28.02.2026) -**Создан полный UI для авторизации:** -- Модальные окна login/register -- Страницы /login, /register, /forgot-password, /reset-password -- UserMenu в сайдбаре -- AuthContext для управления состоянием -## Сделано (полностью) +**Обновлён дизайн страниц авторизации:** -### 1. Auth UI (NEW) +### Сделано (полностью) -**Новые файлы:** +#### Страницы авторизации в стиле сайта +- `/login` — переделана с градиентным фоном, фирменным логотипом, анимациями +- `/register` — аналогично, единый стиль с login +- `/forgot-password` — сброс пароля в новом дизайне +- `/reset-password` — установка нового пароля в новом дизайне -**API клиент:** -- `backend/webui/src/lib/auth.ts` — функции для работы с auth API, хранение токенов +Изменения: +- `bg-gradient-main` вместо простого `bg-base` +- Фирменный логотип GooSeek (font-black italic + иконка Sparkles) +- Gradient blur декорации на фоне +- Карточка с градиентной рамкой и backdrop-blur +- Анимации появления (framer-motion) +- Footer с копирайтом -**Контекст:** -- `backend/webui/src/lib/contexts/AuthContext.tsx` — React контекст для auth состояния +--- -**Компоненты:** -- `backend/webui/src/components/auth/AuthModal.tsx` — модальное окно -- `backend/webui/src/components/auth/LoginForm.tsx` — форма входа -- `backend/webui/src/components/auth/RegisterForm.tsx` — форма регистрации -- `backend/webui/src/components/auth/ForgotPasswordForm.tsx` — сброс пароля -- `backend/webui/src/components/auth/UserMenu.tsx` — меню пользователя -- `backend/webui/src/components/auth/index.ts` — экспорты +**Ранее: Spaces как у Perplexity + коллаборация:** -**Страницы:** -- `backend/webui/src/app/(auth)/login/page.tsx` -- `backend/webui/src/app/(auth)/register/page.tsx` -- `backend/webui/src/app/(auth)/forgot-password/page.tsx` -- `backend/webui/src/app/(auth)/reset-password/page.tsx` -- `backend/webui/src/app/(auth)/layout.tsx` +### Сделано (полностью) -**Интеграция:** -- `providers.tsx` — добавлен AuthProvider и AuthModal -- `Sidebar.tsx` — кнопки входа/регистрации для гостей, профиль для авторизованных +#### 1. Backend - Space Members +- Добавлена таблица `space_members` (space_id, user_id, role) +- Добавлена таблица `space_invites` (приглашения по токену) +- Методы: `GetMembers`, `AddMember`, `RemoveMember`, `IsMember` +- Методы: `CreateInvite`, `GetInviteByToken`, `DeleteInvite` +- Обновлён `GetByMemberID` - возвращает пространства где пользователь участник -**Функционал:** -- Модальные окна для быстрого входа без перехода на страницу -- Отдельные страницы для полноценного входа -- Валидация паролей в реальном времени -- Показ/скрытие пароля -- Запоминание пользователя -- Сброс пароля по email -- Автоматический refresh токенов -- Отображение tier (Free/Pro/Business) +#### 2. Frontend - Types & API +- Добавлены типы: `SpaceMember`, `SpaceInvite` +- Обновлён тип `Space` с полями `members`, `memberCount`, `userId` +- API методы: `fetchSpaceMembers`, `inviteToSpace`, `removeSpaceMember`, `acceptSpaceInvite`, `fetchSpaceThreads` -### 2. Auth Service (Backend) +#### 3. Страница /spaces - Новый дизайн +- Карточки пространств с градиентным фоном +- Показывает: название, описание, кол-во участников, кол-во тредов +- Аватары участников на карточке +- Поиск по пространствам +- Требует авторизации -**Файлы:** -- `backend/internal/auth/types.go` -- `backend/internal/auth/repository.go` -- `backend/cmd/auth-svc/main.go` +#### 4. Страница /spaces/[id] - Детали пространства +- Заголовок с названием и описанием +- Табы: Треды / Участники +- Список тредов пространства с датами +- Список участников с ролями (owner/admin/member) +- Кнопка "Начать новый тред" +- Модалка приглашения участников по email +- Удаление участников (для admin/owner) -**Эндпоинты:** -| Method | Path | Описание | -|--------|------|----------| -| POST | `/api/v1/auth/register` | Регистрация | -| POST | `/api/v1/auth/login` | Вход | -| POST | `/api/v1/auth/refresh` | Обновление токена | -| POST | `/api/v1/auth/logout` | Выход | -| POST | `/api/v1/auth/logout-all` | Выход со всех устройств | -| GET | `/api/v1/auth/validate` | Валидация токена | -| GET | `/api/v1/auth/me` | Профиль | -| PUT | `/api/v1/auth/me` | Обновить профиль | -| POST | `/api/v1/auth/change-password` | Смена пароля | -| POST | `/api/v1/auth/forgot-password` | Запрос сброса | -| POST | `/api/v1/auth/reset-password` | Сброс пароля | +#### 5. Страница /spaces/new - Создание +- Превью карточки в реальном времени +- Выбор иконки (эмодзи) +- Выбор цвета (6 вариантов) +- Переключатель приватности +- AI инструкции для пространства -### 3. Ранее — Аудит безопасности +#### 6. Ранее добавлено +- Селектор модели (Auto/GooSeek 1.0) +- Ollama клиент для бесплатной модели +- Обновлённая кнопка "Войти" в сайдбаре -- Репозитории с фильтрацией по user_id -- JWT middleware во всех сервисах -- Tiered rate limiting (free/pro/business) -- Usage tracking -- LLM limits +### Файлы изменены/созданы + +``` +backend/internal/db/ +└── space_repo.go (UPDATED - members, invites) + +backend/webui/src/ +├── lib/ +│ ├── types.ts (UPDATED - SpaceMember, SpaceInvite) +│ └── api.ts (UPDATED - space members API) +├── app/(main)/spaces/ +│ ├── page.tsx (REWRITTEN - новый дизайн) +│ ├── new/page.tsx (REWRITTEN - с превью) +│ └── [id]/page.tsx (NEW - детали пространства) +└── components/ + ├── Sidebar.tsx (UPDATED - кнопка войти) + └── ChatInput.tsx (UPDATED - селектор модели) + +backend/internal/llm/ +├── ollama.go (NEW) +└── client.go (UPDATED) + +backend/pkg/config/config.go (UPDATED - Ollama config) +backend/cmd/agent-svc/main.go (UPDATED - Ollama support) +``` ## Осталось сделать ### Высокий приоритет: -1. **Проверить компиляцию** — `cd backend && go build ./...` -2. **Протестировать auth flow** — регистрация → логин → refresh → logout -3. **Добавить billing-svc** — интеграция с ЮKassa +1. **Backend API endpoints** — добавить API для: + - `GET /api/v1/spaces/:id/members` + - `POST /api/v1/spaces/:id/invite` + - `DELETE /api/v1/spaces/:id/members/:userId` + - `POST /api/v1/spaces/invite/:token/accept` + - `GET /api/v1/spaces/:id/threads` + +2. **Email отправка** — отправлять email с приглашением + +3. **Ollama в docker-compose** — добавить сервис ollama ### Средний приоритет: 4. **OAuth провайдеры** — Google, GitHub, Yandex -5. **Email уведомления** — подтверждение email, сброс пароля -6. **Страница настроек профиля** — редактирование name, avatar -7. **Страница подписки** — /settings/billing с выбором тарифа +5. **Подтверждение email** — отправка письма при регистрации +6. **Real-time обновления** — WebSocket для тредов в пространстве +7. **Уведомления** — когда кто-то добавляет сообщение в тред ### Низкий приоритет: -8. **2FA** — TOTP аутентификация -9. **Session management** — список активных сессий -10. **Account deletion** — удаление аккаунта +8. **Интеграция оплаты** — ЮKassa для пополнения баланса +9. **2FA** — TOTP аутентификация +10. **Экспорт тредов** — PDF/Markdown ## Контекст -### Новые файлы UI: -``` -backend/webui/src/ -├── lib/ -│ ├── auth.ts (NEW) -│ └── contexts/ -│ └── AuthContext.tsx (NEW) -├── components/ -│ └── auth/ -│ ├── AuthModal.tsx (NEW) -│ ├── LoginForm.tsx (NEW) -│ ├── RegisterForm.tsx (NEW) -│ ├── ForgotPasswordForm.tsx (NEW) -│ ├── UserMenu.tsx (NEW) -│ └── index.ts (NEW) -└── app/ - └── (auth)/ - ├── layout.tsx (NEW) - ├── login/page.tsx (NEW) - ├── register/page.tsx (NEW) - ├── forgot-password/page.tsx (NEW) - └── reset-password/page.tsx (NEW) -``` +### Модели: +| ID | Provider | Тариф | +|----|----------|-------| +| auto | ollama | Бесплатно | +| gooseek-1.0 | timeweb | По тарифу | -### Обновлённые файлы: -``` -backend/webui/src/app/providers.tsx — AuthProvider + AuthModal -backend/webui/src/components/Sidebar.tsx — кнопки auth + профиль -``` - -### Хранение токенов: -``` -localStorage: -- token: JWT access token -- refreshToken: refresh token -- user: JSON с данными пользователя -``` - -### Auth flow: -1. Гость видит кнопки "Войти" / "Регистрация" в сайдбаре -2. Клик открывает модальное окно (или переход на страницу) -3. После успешного входа — сохранение токенов, обновление UI -4. При истечении access token — автоматический refresh -5. При logout — очистка localStorage +### Роли в пространстве: +- `owner` — создатель, полные права +- `admin` — может приглашать/удалять участников +- `member` — может создавать треды diff --git a/backend/cmd/agent-svc/main.go b/backend/cmd/agent-svc/main.go index 5aeb85d..4c062b5 100644 --- a/backend/cmd/agent-svc/main.go +++ b/backend/cmd/agent-svc/main.go @@ -93,15 +93,23 @@ func main() { providerID = "timeweb" modelKey = "gpt-4o" } else if providerID == "" { - providerID = "openai" - modelKey = "gpt-4o-mini" + providerID = "ollama" + modelKey = cfg.OllamaModelKey + } + + baseURL := cfg.TimewebAPIBaseURL + if providerID == "ollama" { + baseURL = cfg.OllamaBaseURL + if modelKey == "" { + modelKey = cfg.OllamaModelKey + } } llmClient, err := llm.NewClient(llm.ProviderConfig{ ProviderID: providerID, ModelKey: modelKey, APIKey: getAPIKey(cfg, providerID), - BaseURL: cfg.TimewebAPIBaseURL, + BaseURL: baseURL, AgentAccessID: cfg.TimewebAgentAccessID, }) if err != nil { @@ -200,6 +208,8 @@ func main() { func getAPIKey(cfg *config.Config, providerID string) string { switch providerID { + case "ollama": + return "" case "timeweb": return cfg.TimewebAPIKey case "openai": diff --git a/backend/cmd/api-gateway/main.go b/backend/cmd/api-gateway/main.go index 668999a..02c179c 100644 --- a/backend/cmd/api-gateway/main.go +++ b/backend/cmd/api-gateway/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "fmt" "io" "log" @@ -15,6 +16,7 @@ import ( "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gooseek/backend/pkg/config" "github.com/gooseek/backend/pkg/middleware" + "github.com/redis/go-redis/v9" ) var svcURLs map[string]string @@ -25,6 +27,19 @@ func main() { log.Fatal("Failed to load config:", err) } + opt, err := redis.ParseURL(cfg.RedisURL) + if err != nil { + log.Printf("Warning: failed to parse Redis URL, rate limiting will be disabled: %v", err) + } + var redisClient *redis.Client + if opt != nil { + redisClient = redis.NewClient(opt) + if _, err := redisClient.Ping(context.Background()).Result(); err != nil { + log.Printf("Warning: Redis not available, rate limiting will be disabled: %v", err) + redisClient = nil + } + } + svcURLs = map[string]string{ "auth": cfg.AuthSvcURL, "chat": cfg.ChatSvcURL, @@ -62,14 +77,17 @@ func main() { AllowGuest: true, })) - app.Use(middleware.TieredRateLimit(middleware.TieredRateLimitConfig{ - Tiers: map[string]middleware.TierConfig{ - "free": {Max: 60, Window: time.Minute}, - "pro": {Max: 300, Window: time.Minute}, - "business": {Max: 1000, Window: time.Minute}, - }, - DefaultTier: "free", - })) + if redisClient != nil { + app.Use(middleware.TieredRateLimit(middleware.TieredRateLimitConfig{ + RedisClient: redisClient, + Tiers: map[string]middleware.TierConfig{ + "free": {Max: 60, Window: time.Minute}, + "pro": {Max: 300, Window: time.Minute}, + "business": {Max: 1000, Window: time.Minute}, + }, + DefaultTier: "free", + })) + } app.Get("/health", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"status": "ok"}) diff --git a/backend/deploy/docker/Dockerfile.all b/backend/deploy/docker/Dockerfile.all index b155475..71b55e2 100644 --- a/backend/deploy/docker/Dockerfile.all +++ b/backend/deploy/docker/Dockerfile.all @@ -12,6 +12,7 @@ COPY . . # Build all services RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/api-gateway ./cmd/api-gateway +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/auth-svc ./cmd/auth-svc RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/agent-svc ./cmd/agent-svc RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/chat-svc ./cmd/chat-svc RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/search-svc ./cmd/search-svc diff --git a/backend/deploy/docker/docker-compose.yml b/backend/deploy/docker/docker-compose.yml index bc3f3cd..675040c 100644 --- a/backend/deploy/docker/docker-compose.yml +++ b/backend/deploy/docker/docker-compose.yml @@ -41,6 +41,7 @@ services: - LEARNING_SVC_URL=http://learning-svc:3034 - ADMIN_SVC_URL=http://admin-svc:3040 - JWT_SECRET=${JWT_SECRET} + - REDIS_URL=redis://redis:6379 ports: - "3015:3015" depends_on: @@ -49,6 +50,7 @@ services: - agent-svc - thread-svc - admin-svc + - redis networks: - gooseek diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go index 02932f7..d8f118f 100644 --- a/backend/internal/auth/repository.go +++ b/backend/internal/auth/repository.go @@ -38,6 +38,7 @@ func (r *Repository) RunMigrations(ctx context.Context) error { avatar TEXT, role VARCHAR(50) DEFAULT 'user', tier VARCHAR(50) DEFAULT 'free', + balance DECIMAL(12,2) DEFAULT 0, email_verified BOOLEAN DEFAULT FALSE, provider VARCHAR(50) DEFAULT 'local', provider_id VARCHAR(255), @@ -69,6 +70,8 @@ func (r *Repository) RunMigrations(ctx context.Context) error { created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_password_reset_tokens ON password_reset_tokens(token)`, + + `ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS balance DECIMAL(12,2) DEFAULT 0`, } for _, m := range migrations { @@ -125,18 +128,19 @@ func (r *Repository) CreateUser(ctx context.Context, email, password, name strin func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*User, error) { query := ` - SELECT id, email, password_hash, name, avatar, role, tier, email_verified, + SELECT id, email, password_hash, name, avatar, role, tier, balance, email_verified, provider, provider_id, last_login_at, created_at, updated_at FROM auth_users WHERE email = $1 ` user := &User{} - var lastLogin, avatar, providerID sql.NullString + var avatar, providerID sql.NullString var lastLoginTime sql.NullTime + var balance sql.NullFloat64 err := r.db.QueryRowContext(ctx, query, email).Scan( &user.ID, &user.Email, &user.PasswordHash, &user.Name, &avatar, - &user.Role, &user.Tier, &user.EmailVerified, &user.Provider, + &user.Role, &user.Tier, &balance, &user.EmailVerified, &user.Provider, &providerID, &lastLoginTime, &user.CreatedAt, &user.UpdatedAt, ) @@ -156,14 +160,16 @@ func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*User, e if lastLoginTime.Valid { user.LastLoginAt = lastLoginTime.Time } - _ = lastLogin + if balance.Valid { + user.Balance = balance.Float64 + } return user, nil } func (r *Repository) GetUserByID(ctx context.Context, id string) (*User, error) { query := ` - SELECT id, email, password_hash, name, avatar, role, tier, email_verified, + SELECT id, email, password_hash, name, avatar, role, tier, balance, email_verified, provider, provider_id, last_login_at, created_at, updated_at FROM auth_users WHERE id = $1 ` @@ -171,10 +177,11 @@ func (r *Repository) GetUserByID(ctx context.Context, id string) (*User, error) user := &User{} var avatar, providerID sql.NullString var lastLoginTime sql.NullTime + var balance sql.NullFloat64 err := r.db.QueryRowContext(ctx, query, id).Scan( &user.ID, &user.Email, &user.PasswordHash, &user.Name, &avatar, - &user.Role, &user.Tier, &user.EmailVerified, &user.Provider, + &user.Role, &user.Tier, &balance, &user.EmailVerified, &user.Provider, &providerID, &lastLoginTime, &user.CreatedAt, &user.UpdatedAt, ) @@ -194,6 +201,9 @@ func (r *Repository) GetUserByID(ctx context.Context, id string) (*User, error) if lastLoginTime.Valid { user.LastLoginAt = lastLoginTime.Time } + if balance.Valid { + user.Balance = balance.Float64 + } return user, nil } @@ -254,6 +264,22 @@ func (r *Repository) UpdateRole(ctx context.Context, userID string, role UserRol return err } +func (r *Repository) UpdateBalance(ctx context.Context, userID string, amount float64) error { + _, err := r.db.ExecContext(ctx, + "UPDATE auth_users SET balance = balance + $2, updated_at = NOW() WHERE id = $1", + userID, amount, + ) + return err +} + +func (r *Repository) SetBalance(ctx context.Context, userID string, balance float64) error { + _, err := r.db.ExecContext(ctx, + "UPDATE auth_users SET balance = $2, updated_at = NOW() WHERE id = $1", + userID, balance, + ) + return err +} + func (r *Repository) CreateRefreshToken(ctx context.Context, userID, userAgent, ip string, duration time.Duration) (*RefreshToken, error) { token := generateSecureToken(32) diff --git a/backend/internal/auth/types.go b/backend/internal/auth/types.go index fa32b54..ef60b58 100644 --- a/backend/internal/auth/types.go +++ b/backend/internal/auth/types.go @@ -12,6 +12,7 @@ type User struct { Avatar string `json:"avatar,omitempty"` Role string `json:"role"` Tier string `json:"tier"` + Balance float64 `json:"balance"` EmailVerified bool `json:"emailVerified"` Provider string `json:"provider"` ProviderID string `json:"providerId,omitempty"` diff --git a/backend/internal/db/space_repo.go b/backend/internal/db/space_repo.go index fba4999..3fbb9e4 100644 --- a/backend/internal/db/space_repo.go +++ b/backend/internal/db/space_repo.go @@ -22,6 +22,30 @@ type Space struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` ThreadCount int `json:"threadCount,omitempty"` + Members []*SpaceMember `json:"members,omitempty"` + MemberCount int `json:"memberCount,omitempty"` +} + +type SpaceMember struct { + ID string `json:"id"` + SpaceID string `json:"spaceId"` + UserID string `json:"userId"` + Role string `json:"role"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Avatar string `json:"avatar,omitempty"` + JoinedAt time.Time `json:"joinedAt"` +} + +type SpaceInvite struct { + ID string `json:"id"` + SpaceID string `json:"spaceId"` + Email string `json:"email"` + Role string `json:"role"` + InvitedBy string `json:"invitedBy"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` + CreatedAt time.Time `json:"createdAt"` } type SpaceRepository struct { @@ -50,6 +74,28 @@ func (r *SpaceRepository) RunMigrations(ctx context.Context) error { updated_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_spaces_user ON spaces(user_id)`, + `CREATE TABLE IF NOT EXISTS space_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + role VARCHAR(20) DEFAULT 'member', + joined_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(space_id, user_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_space_members_space ON space_members(space_id)`, + `CREATE INDEX IF NOT EXISTS idx_space_members_user ON space_members(user_id)`, + `CREATE TABLE IF NOT EXISTS space_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + role VARCHAR(20) DEFAULT 'member', + invited_by UUID NOT NULL, + token VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_space_invites_token ON space_invites(token)`, + `CREATE INDEX IF NOT EXISTS idx_space_invites_email ON space_invites(email)`, } for _, m := range migrations { @@ -175,3 +221,176 @@ func (r *SpaceRepository) Delete(ctx context.Context, id, userID string) error { } return nil } + +func (r *SpaceRepository) GetMembers(ctx context.Context, spaceID string) ([]*SpaceMember, error) { + query := ` + SELECT sm.id, sm.space_id, sm.user_id, sm.role, sm.joined_at, + COALESCE(u.email, '') as email, + COALESCE(u.name, '') as name, + COALESCE(u.avatar, '') as avatar + FROM space_members sm + LEFT JOIN auth_users u ON sm.user_id = u.id + WHERE sm.space_id = $1 + ORDER BY sm.joined_at ASC + ` + + rows, err := r.db.db.QueryContext(ctx, query, spaceID) + if err != nil { + return nil, err + } + defer rows.Close() + + var members []*SpaceMember + for rows.Next() { + var m SpaceMember + if err := rows.Scan(&m.ID, &m.SpaceID, &m.UserID, &m.Role, &m.JoinedAt, &m.Email, &m.Name, &m.Avatar); err != nil { + return nil, err + } + members = append(members, &m) + } + return members, nil +} + +func (r *SpaceRepository) AddMember(ctx context.Context, spaceID, userID, role string) error { + query := ` + INSERT INTO space_members (space_id, user_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (space_id, user_id) DO NOTHING + ` + _, err := r.db.db.ExecContext(ctx, query, spaceID, userID, role) + return err +} + +func (r *SpaceRepository) RemoveMember(ctx context.Context, spaceID, userID string) error { + result, err := r.db.db.ExecContext(ctx, "DELETE FROM space_members WHERE space_id = $1 AND user_id = $2", spaceID, userID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrNotFound + } + return nil +} + +func (r *SpaceRepository) UpdateMemberRole(ctx context.Context, spaceID, userID, role string) error { + result, err := r.db.db.ExecContext(ctx, "UPDATE space_members SET role = $3 WHERE space_id = $1 AND user_id = $2", spaceID, userID, role) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrNotFound + } + return nil +} + +func (r *SpaceRepository) IsMember(ctx context.Context, spaceID, userID string) (bool, string, error) { + var role string + err := r.db.db.QueryRowContext(ctx, "SELECT role FROM space_members WHERE space_id = $1 AND user_id = $2", spaceID, userID).Scan(&role) + if err == sql.ErrNoRows { + return false, "", nil + } + if err != nil { + return false, "", err + } + return true, role, nil +} + +func (r *SpaceRepository) GetByMemberID(ctx context.Context, userID string) ([]*Space, error) { + query := ` + SELECT DISTINCT s.id, s.user_id, s.name, s.description, s.icon, s.color, + s.custom_instructions, s.default_focus_mode, s.default_model, + s.is_public, s.settings, s.created_at, s.updated_at, + (SELECT COUNT(*) FROM threads WHERE space_id = s.id) as thread_count, + (SELECT COUNT(*) FROM space_members WHERE space_id = s.id) as member_count + FROM spaces s + LEFT JOIN space_members sm ON s.id = sm.space_id + WHERE s.user_id = $1 OR sm.user_id = $1 + ORDER BY s.updated_at DESC + ` + + rows, err := r.db.db.QueryContext(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var spaces []*Space + for rows.Next() { + var s Space + var settingsJSON []byte + + if err := rows.Scan( + &s.ID, &s.UserID, &s.Name, &s.Description, &s.Icon, &s.Color, + &s.CustomInstructions, &s.DefaultFocusMode, &s.DefaultModel, + &s.IsPublic, &settingsJSON, &s.CreatedAt, &s.UpdatedAt, &s.ThreadCount, &s.MemberCount, + ); err != nil { + return nil, err + } + + json.Unmarshal(settingsJSON, &s.Settings) + spaces = append(spaces, &s) + } + + return spaces, nil +} + +func (r *SpaceRepository) CreateInvite(ctx context.Context, invite *SpaceInvite) error { + query := ` + INSERT INTO space_invites (space_id, email, role, invited_by, token, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at + ` + return r.db.db.QueryRowContext(ctx, query, + invite.SpaceID, invite.Email, invite.Role, invite.InvitedBy, invite.Token, invite.ExpiresAt, + ).Scan(&invite.ID, &invite.CreatedAt) +} + +func (r *SpaceRepository) GetInviteByToken(ctx context.Context, token string) (*SpaceInvite, error) { + query := ` + SELECT id, space_id, email, role, invited_by, token, expires_at, created_at + FROM space_invites + WHERE token = $1 AND expires_at > NOW() + ` + var inv SpaceInvite + err := r.db.db.QueryRowContext(ctx, query, token).Scan( + &inv.ID, &inv.SpaceID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &inv, nil +} + +func (r *SpaceRepository) DeleteInvite(ctx context.Context, id string) error { + _, err := r.db.db.ExecContext(ctx, "DELETE FROM space_invites WHERE id = $1", id) + return err +} + +func (r *SpaceRepository) GetInvitesBySpace(ctx context.Context, spaceID string) ([]*SpaceInvite, error) { + query := ` + SELECT id, space_id, email, role, invited_by, token, expires_at, created_at + FROM space_invites + WHERE space_id = $1 AND expires_at > NOW() + ORDER BY created_at DESC + ` + rows, err := r.db.db.QueryContext(ctx, query, spaceID) + if err != nil { + return nil, err + } + defer rows.Close() + + var invites []*SpaceInvite + for rows.Next() { + var inv SpaceInvite + if err := rows.Scan(&inv.ID, &inv.SpaceID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt); err != nil { + return nil, err + } + invites = append(invites, &inv) + } + return invites, nil +} diff --git a/backend/internal/llm/client.go b/backend/internal/llm/client.go index 74d7807..deb7b71 100644 --- a/backend/internal/llm/client.go +++ b/backend/internal/llm/client.go @@ -79,6 +79,11 @@ type ProviderConfig struct { func NewClient(cfg ProviderConfig) (Client, error) { switch cfg.ProviderID { + case "ollama": + return NewOllamaClient(OllamaConfig{ + BaseURL: cfg.BaseURL, + ModelKey: cfg.ModelKey, + }) case "timeweb": return NewTimewebClient(TimewebConfig{ BaseURL: cfg.BaseURL, diff --git a/backend/internal/llm/ollama.go b/backend/internal/llm/ollama.go new file mode 100644 index 0000000..5b19927 --- /dev/null +++ b/backend/internal/llm/ollama.go @@ -0,0 +1,233 @@ +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type OllamaClient struct { + baseClient + httpClient *http.Client + baseURL string +} + +type OllamaConfig struct { + BaseURL string + ModelKey string +} + +func NewOllamaClient(cfg OllamaConfig) (*OllamaClient, error) { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "http://ollama:11434" + } + + modelKey := cfg.ModelKey + if modelKey == "" { + modelKey = "llama3.2" + } + + return &OllamaClient{ + baseClient: baseClient{ + providerID: "ollama", + modelKey: modelKey, + }, + httpClient: &http.Client{ + Timeout: 300 * time.Second, + }, + baseURL: baseURL, + }, nil +} + +type ollamaChatRequest struct { + Model string `json:"model"` + Messages []ollamaMessage `json:"messages"` + Stream bool `json:"stream"` + Options *ollamaOptions `json:"options,omitempty"` +} + +type ollamaMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ollamaOptions struct { + Temperature float64 `json:"temperature,omitempty"` + NumPredict int `json:"num_predict,omitempty"` + TopP float64 `json:"top_p,omitempty"` + Stop []string `json:"stop,omitempty"` +} + +type ollamaChatResponse struct { + Model string `json:"model"` + CreatedAt string `json:"created_at"` + Message ollamaMessage `json:"message"` + Done bool `json:"done"` +} + +func (c *OllamaClient) StreamText(ctx context.Context, req StreamRequest) (<-chan StreamChunk, error) { + messages := make([]ollamaMessage, 0, len(req.Messages)) + for _, m := range req.Messages { + messages = append(messages, ollamaMessage{ + Role: string(m.Role), + Content: m.Content, + }) + } + + chatReq := ollamaChatRequest{ + Model: c.modelKey, + Messages: messages, + Stream: true, + } + + if req.Options.MaxTokens > 0 || req.Options.Temperature > 0 || req.Options.TopP > 0 || len(req.Options.StopWords) > 0 { + chatReq.Options = &ollamaOptions{} + if req.Options.MaxTokens > 0 { + chatReq.Options.NumPredict = req.Options.MaxTokens + } + if req.Options.Temperature > 0 { + chatReq.Options.Temperature = req.Options.Temperature + } + if req.Options.TopP > 0 { + chatReq.Options.TopP = req.Options.TopP + } + if len(req.Options.StopWords) > 0 { + chatReq.Options.Stop = req.Options.StopWords + } + } + + body, err := json.Marshal(chatReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/api/chat", c.baseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Ollama API error: status %d, body: %s", resp.StatusCode, string(body)) + } + + ch := make(chan StreamChunk, 100) + go func() { + defer close(ch) + defer resp.Body.Close() + + reader := bufio.NewReader(resp.Body) + + for { + line, err := reader.ReadString('\n') + if err != nil { + if err != io.EOF { + return + } + return + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var streamResp ollamaChatResponse + if err := json.Unmarshal([]byte(line), &streamResp); err != nil { + continue + } + + if streamResp.Message.Content != "" { + ch <- StreamChunk{ContentChunk: streamResp.Message.Content} + } + + if streamResp.Done { + ch <- StreamChunk{FinishReason: "stop"} + return + } + } + }() + + return ch, nil +} + +func (c *OllamaClient) GenerateText(ctx context.Context, req StreamRequest) (string, error) { + messages := make([]ollamaMessage, 0, len(req.Messages)) + for _, m := range req.Messages { + messages = append(messages, ollamaMessage{ + Role: string(m.Role), + Content: m.Content, + }) + } + + chatReq := ollamaChatRequest{ + Model: c.modelKey, + Messages: messages, + Stream: false, + } + + if req.Options.MaxTokens > 0 || req.Options.Temperature > 0 || req.Options.TopP > 0 { + chatReq.Options = &ollamaOptions{} + if req.Options.MaxTokens > 0 { + chatReq.Options.NumPredict = req.Options.MaxTokens + } + if req.Options.Temperature > 0 { + chatReq.Options.Temperature = req.Options.Temperature + } + if req.Options.TopP > 0 { + chatReq.Options.TopP = req.Options.TopP + } + } + + body, err := json.Marshal(chatReq) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/api/chat", c.baseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("Ollama API error: status %d, body: %s", resp.StatusCode, string(body)) + } + + var chatResp ollamaChatResponse + if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if chatResp.Message.Content == "" { + return "", errors.New("empty response from Ollama") + } + + return chatResp.Message.Content, nil +} diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 7a44148..9faa62f 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -70,6 +70,10 @@ type Config struct { TimewebAPIKey string TimewebProxySource string + // Ollama (local LLM) + OllamaBaseURL string + OllamaModelKey string + // Timeouts HTTPTimeout time.Duration LLMTimeout time.Duration @@ -141,6 +145,9 @@ func Load() (*Config, error) { TimewebAPIKey: getEnv("TIMEWEB_API_KEY", ""), TimewebProxySource: getEnv("TIMEWEB_X_PROXY_SOURCE", "gooseek"), + OllamaBaseURL: getEnv("OLLAMA_BASE_URL", "http://ollama:11434"), + OllamaModelKey: getEnv("OLLAMA_MODEL", "llama3.2"), + HTTPTimeout: time.Duration(getEnvInt("HTTP_TIMEOUT_MS", 60000)) * time.Millisecond, LLMTimeout: time.Duration(getEnvInt("LLM_TIMEOUT_MS", 120000)) * time.Millisecond, ScrapeTimeout: time.Duration(getEnvInt("SCRAPE_TIMEOUT_MS", 25000)) * time.Millisecond, diff --git a/backend/pkg/middleware/ratelimit_redis.go b/backend/pkg/middleware/ratelimit_redis.go index b5846e3..3662b03 100644 --- a/backend/pkg/middleware/ratelimit_redis.go +++ b/backend/pkg/middleware/ratelimit_redis.go @@ -144,6 +144,7 @@ type TieredRateLimitConfig struct { RedisClient *redis.Client KeyPrefix string Tiers map[string]TierConfig + DefaultTier string GetTierFunc func(*fiber.Ctx) string KeyFunc func(*fiber.Ctx) string } @@ -157,16 +158,31 @@ func TieredRateLimit(cfg TieredRateLimitConfig) fiber.Handler { if cfg.KeyPrefix == "" { cfg.KeyPrefix = "ratelimit:tiered" } + if cfg.DefaultTier == "" { + cfg.DefaultTier = "free" + } if cfg.GetTierFunc == nil { - cfg.GetTierFunc = func(c *fiber.Ctx) string { return "default" } + cfg.GetTierFunc = func(c *fiber.Ctx) string { + tier := GetUserTier(c) + if tier == "" { + return cfg.DefaultTier + } + return tier + } } if cfg.KeyFunc == nil { - cfg.KeyFunc = func(c *fiber.Ctx) string { return c.IP() } + cfg.KeyFunc = func(c *fiber.Ctx) string { + userID := GetUserID(c) + if userID != "" { + return "user:" + userID + } + return "ip:" + c.IP() + } } - defaultTier := TierConfig{Max: 60, Window: time.Minute} - if _, ok := cfg.Tiers["default"]; !ok { - cfg.Tiers["default"] = defaultTier + defaultTierCfg := TierConfig{Max: 60, Window: time.Minute} + if _, ok := cfg.Tiers[cfg.DefaultTier]; !ok { + cfg.Tiers[cfg.DefaultTier] = defaultTierCfg } return func(c *fiber.Ctx) error { @@ -174,7 +190,7 @@ func TieredRateLimit(cfg TieredRateLimitConfig) fiber.Handler { tier := cfg.GetTierFunc(c) tierCfg, ok := cfg.Tiers[tier] if !ok { - tierCfg = cfg.Tiers["default"] + tierCfg = cfg.Tiers[cfg.DefaultTier] } key := fmt.Sprintf("%s:%s:%s", cfg.KeyPrefix, tier, cfg.KeyFunc(c)) diff --git a/backend/pkg/middleware/ratelimit_tiered.go b/backend/pkg/middleware/ratelimit_tiered.go deleted file mode 100644 index da008c7..0000000 --- a/backend/pkg/middleware/ratelimit_tiered.go +++ /dev/null @@ -1,158 +0,0 @@ -package middleware - -import ( - "sync" - "time" - - "github.com/gofiber/fiber/v2" -) - -type TierConfig struct { - Max int - Window time.Duration -} - -type TieredRateLimitConfig struct { - Tiers map[string]TierConfig - DefaultTier string - KeyFunc func(*fiber.Ctx) string - GetTierFunc func(*fiber.Ctx) string -} - -type tieredRateLimiter struct { - requests map[string][]time.Time - mu sync.RWMutex - tiers map[string]TierConfig -} - -func newTieredRateLimiter(tiers map[string]TierConfig) *tieredRateLimiter { - rl := &tieredRateLimiter{ - requests: make(map[string][]time.Time), - tiers: tiers, - } - go rl.cleanup() - return rl -} - -func (rl *tieredRateLimiter) cleanup() { - ticker := time.NewTicker(time.Minute) - for range ticker.C { - rl.mu.Lock() - now := time.Now() - for key, times := range rl.requests { - var valid []time.Time - for _, t := range times { - if now.Sub(t) < 5*time.Minute { - valid = append(valid, t) - } - } - if len(valid) == 0 { - delete(rl.requests, key) - } else { - rl.requests[key] = valid - } - } - rl.mu.Unlock() - } -} - -func (rl *tieredRateLimiter) allow(key string, tier string) (bool, int, int) { - rl.mu.Lock() - defer rl.mu.Unlock() - - cfg, ok := rl.tiers[tier] - if !ok { - cfg = rl.tiers["free"] - } - - now := time.Now() - windowStart := now.Add(-cfg.Window) - - times := rl.requests[key] - var valid []time.Time - for _, t := range times { - if t.After(windowStart) { - valid = append(valid, t) - } - } - - remaining := cfg.Max - len(valid) - if remaining <= 0 { - rl.requests[key] = valid - return false, 0, cfg.Max - } - - rl.requests[key] = append(valid, now) - return true, remaining - 1, cfg.Max -} - -func TieredRateLimit(config TieredRateLimitConfig) fiber.Handler { - if config.Tiers == nil { - config.Tiers = map[string]TierConfig{ - "free": {Max: 60, Window: time.Minute}, - "pro": {Max: 300, Window: time.Minute}, - "business": {Max: 1000, Window: time.Minute}, - } - } - if config.DefaultTier == "" { - config.DefaultTier = "free" - } - if config.KeyFunc == nil { - config.KeyFunc = func(c *fiber.Ctx) string { - userID := GetUserID(c) - if userID != "" { - return "user:" + userID - } - return "ip:" + c.IP() - } - } - if config.GetTierFunc == nil { - config.GetTierFunc = func(c *fiber.Ctx) string { - tier := GetUserTier(c) - if tier == "" { - return config.DefaultTier - } - return tier - } - } - - limiter := newTieredRateLimiter(config.Tiers) - - return func(c *fiber.Ctx) error { - key := config.KeyFunc(c) - tier := config.GetTierFunc(c) - - allowed, remaining, limit := limiter.allow(key, tier) - - c.Set("X-RateLimit-Limit", formatInt(limit)) - c.Set("X-RateLimit-Remaining", formatInt(remaining)) - c.Set("X-RateLimit-Tier", tier) - - if !allowed { - c.Set("Retry-After", "60") - return c.Status(429).JSON(fiber.Map{ - "error": "Rate limit exceeded", - "tier": tier, - "limit": limit, - "retryAfter": 60, - }) - } - - return c.Next() - } -} - -func formatInt(n int) string { - if n < 0 { - n = 0 - } - s := "" - if n == 0 { - return "0" - } - for n > 0 { - s = string(rune('0'+n%10)) + s - n /= 10 - } - return s -} diff --git a/backend/webui/next.config.mjs b/backend/webui/next.config.mjs index 88f4a36..7058e66 100644 --- a/backend/webui/next.config.mjs +++ b/backend/webui/next.config.mjs @@ -3,13 +3,14 @@ const nextConfig = { output: 'standalone', reactStrictMode: true, env: { - API_URL: process.env.API_URL || 'http://api-gateway:3015', + API_URL: process.env.API_URL || 'http://localhost:3015', }, async rewrites() { + const apiUrl = process.env.API_URL || 'http://localhost:3015'; return [ { source: '/api/:path*', - destination: `${process.env.API_URL || 'http://api-gateway:3015'}/api/:path*`, + destination: `${apiUrl}/api/:path*`, }, ]; }, diff --git a/backend/webui/src/app/(auth)/forgot-password/page.tsx b/backend/webui/src/app/(auth)/forgot-password/page.tsx index 5ed4e53..9acb2f1 100644 --- a/backend/webui/src/app/(auth)/forgot-password/page.tsx +++ b/backend/webui/src/app/(auth)/forgot-password/page.tsx @@ -2,32 +2,61 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { Sparkles } from 'lucide-react'; import { ForgotPasswordForm } from '@/components/auth'; export default function ForgotPasswordPage() { const router = useRouter(); return ( -
-
- -
- G +
+ {/* Background decorations */} +
+
+
+
+
+ + {/* Header */} +
+ +
+
- GooSeek + + GooSeek +
-
-
-
- router.push('/login')} - /> + {/* Main content */} +
+ + {/* Card with gradient border */} +
+
+
+ router.push('/login')} + /> +
-
+
+ + {/* Footer */} +
+

+ © 2026 GooSeek. Все права защищены. +

+
); } diff --git a/backend/webui/src/app/(auth)/login/page.tsx b/backend/webui/src/app/(auth)/login/page.tsx index 0a399ae..da03a60 100644 --- a/backend/webui/src/app/(auth)/login/page.tsx +++ b/backend/webui/src/app/(auth)/login/page.tsx @@ -3,6 +3,8 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { Sparkles } from 'lucide-react'; import { LoginForm } from '@/components/auth'; import { useAuth } from '@/lib/contexts/AuthContext'; @@ -18,45 +20,78 @@ export default function LoginPage() { if (isLoading) { return ( -
+
); } return ( -
-
- -
- G +
+ {/* Background decorations */} +
+
+
+
+
+ + {/* Header */} +
+ +
+
- GooSeek + + GooSeek +
-
-
-
- router.push('/')} - onSwitchToRegister={() => router.push('/register')} - /> + {/* Main content */} +
+ + {/* Card with gradient border */} +
+
+
+ router.push('/')} + onSwitchToRegister={() => router.push('/register')} + /> +
-

+ {/* Footer text */} + Продолжая, вы соглашаетесь с{' '} - + условиями {' '} и{' '} - + политикой конфиденциальности -

-
+ +
+ + {/* Footer */} +
+

+ © 2026 GooSeek. Все права защищены. +

+
); } diff --git a/backend/webui/src/app/(auth)/register/page.tsx b/backend/webui/src/app/(auth)/register/page.tsx index 29b7604..6c1247c 100644 --- a/backend/webui/src/app/(auth)/register/page.tsx +++ b/backend/webui/src/app/(auth)/register/page.tsx @@ -3,6 +3,8 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { Sparkles } from 'lucide-react'; import { RegisterForm } from '@/components/auth'; import { useAuth } from '@/lib/contexts/AuthContext'; @@ -18,41 +20,74 @@ export default function RegisterPage() { if (isLoading) { return ( -
+
); } return ( -
-
- -
- G +
+ {/* Background decorations */} +
+
+
+
+
+ + {/* Header */} +
+ +
+
- GooSeek + + GooSeek +
-
-
-
- router.push('/')} - onSwitchToLogin={() => router.push('/login')} - /> + {/* Main content */} +
+ + {/* Card with gradient border */} +
+
+
+ router.push('/')} + onSwitchToLogin={() => router.push('/login')} + /> +
-

+ {/* Footer text */} + Уже есть аккаунт?{' '} - + Войти -

-
+ +
+ + {/* Footer */} +
+

+ © 2026 GooSeek. Все права защищены. +

+
); } diff --git a/backend/webui/src/app/(auth)/reset-password/page.tsx b/backend/webui/src/app/(auth)/reset-password/page.tsx index 819bc41..7f6d50d 100644 --- a/backend/webui/src/app/(auth)/reset-password/page.tsx +++ b/backend/webui/src/app/(auth)/reset-password/page.tsx @@ -3,7 +3,8 @@ import { useState, FormEvent, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; -import { Lock, Loader2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { Lock, Loader2, Eye, EyeOff, CheckCircle, AlertCircle, Sparkles, Check, X } from 'lucide-react'; import { resetPassword } from '@/lib/auth'; function ResetPasswordContent() { @@ -18,6 +19,13 @@ function ResetPasswordContent() { const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); + const passwordRequirements = [ + { met: password.length >= 8, text: 'Минимум 8 символов' }, + { met: /[A-Z]/.test(password), text: 'Заглавная буква' }, + { met: /[a-z]/.test(password), text: 'Строчная буква' }, + { met: /[0-9]/.test(password), text: 'Цифра' }, + ]; + const isPasswordValid = password.length >= 8; const doPasswordsMatch = password === confirmPassword && confirmPassword.length > 0; @@ -64,7 +72,7 @@ function ResetPasswordContent() {

Запросить новую ссылку @@ -74,7 +82,12 @@ function ResetPasswordContent() { if (isSuccess) { return ( -
+
@@ -84,11 +97,11 @@ function ResetPasswordContent() {

-
+ ); } @@ -100,9 +113,13 @@ function ResetPasswordContent() {
{error && ( -
+ {error} -
+ )}
@@ -133,6 +150,22 @@ function ResetPasswordContent() { {showPassword ? : }
+ + {password.length > 0 && ( +
+ {passwordRequirements.map((req, i) => ( +
+ {req.met ? : } + {req.text} +
+ ))} +
+ )}
@@ -149,11 +182,26 @@ function ResetPasswordContent() { placeholder="••••••••" required autoComplete="new-password" - className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl + className={`w-full pl-11 pr-11 py-3 bg-surface/50 border rounded-xl text-primary placeholder:text-muted - focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 - transition-all duration-200" + focus:outline-none focus:ring-1 transition-all duration-200 + ${ + confirmPassword.length > 0 + ? doPasswordsMatch + ? 'border-success/50 focus:border-success/50 focus:ring-success/20' + : 'border-error/50 focus:border-error/50 focus:ring-error/20' + : 'border-border focus:border-accent/50 focus:ring-accent/20' + }`} /> + {confirmPassword.length > 0 && ( + + {doPasswordsMatch ? ( + + ) : ( + + )} + + )}
@@ -180,29 +228,56 @@ function ResetPasswordContent() { export default function ResetPasswordPage() { return ( -
-
- -
- G +
+ {/* Background decorations */} +
+
+
+
+
+ + {/* Header */} +
+ +
+
- GooSeek + + GooSeek +
-
-
-
- - -
- }> - - + {/* Main content */} +
+ + {/* Card with gradient border */} +
+
+
+ + +
+ }> + + +
-
+
+ + {/* Footer */} +
+

+ © 2026 GooSeek. Все права защищены. +

+
); } diff --git a/backend/webui/src/app/(main)/layout.tsx b/backend/webui/src/app/(main)/layout.tsx index 8beed32..61e2afb 100644 --- a/backend/webui/src/app/(main)/layout.tsx +++ b/backend/webui/src/app/(main)/layout.tsx @@ -60,11 +60,11 @@ export default function MainLayout({ children }: { children: React.ReactNode }) className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm" /> setSidebarOpen(false)} /> diff --git a/backend/webui/src/app/(main)/settings/page.tsx b/backend/webui/src/app/(main)/settings/page.tsx index 2d9adf2..fe73210 100644 --- a/backend/webui/src/app/(main)/settings/page.tsx +++ b/backend/webui/src/app/(main)/settings/page.tsx @@ -1,209 +1,124 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; import { motion } from 'framer-motion'; -import { Shield, Zap, Scale, Sparkles, Download, Trash2, Bell, Eye, Languages, Plug, ChevronDown } from 'lucide-react'; -import * as Switch from '@radix-ui/react-switch'; -import { useLanguage, SupportedLanguage } from '@/lib/contexts/LanguageContext'; -import { ConnectorsSettings } from '@/components/settings/ConnectorsSettings'; +import { User, CreditCard, Settings, LogIn } from 'lucide-react'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { AccountTab } from '@/components/settings/AccountTab'; +import { BillingTab } from '@/components/settings/BillingTab'; +import { PreferencesTab } from '@/components/settings/PreferencesTab'; + +type TabId = 'account' | 'billing' | 'preferences'; + +interface Tab { + id: TabId; + label: string; + icon: React.ElementType; + requiresAuth: boolean; +} + +const tabs: Tab[] = [ + { id: 'account', label: 'Аккаунт', icon: User, requiresAuth: true }, + { id: 'billing', label: 'Оплата', icon: CreditCard, requiresAuth: true }, + { id: 'preferences', label: 'Настройки', icon: Settings, requiresAuth: false }, +]; export default function SettingsPage() { - const [mode, setMode] = useState('balanced'); - const [saveHistory, setSaveHistory] = useState(true); - const [personalization, setPersonalization] = useState(true); - const [notifications, setNotifications] = useState(false); - const [showConnectors, setShowConnectors] = useState(false); - const { language, setLanguage } = useLanguage(); + const searchParams = useSearchParams(); + const router = useRouter(); + const { user, isAuthenticated, isLoading, showAuthModal } = useAuth(); + + const tabParam = searchParams.get('tab') as TabId | null; + const [activeTab, setActiveTab] = useState(tabParam && tabs.some(t => t.id === tabParam) ? tabParam : 'preferences'); + + useEffect(() => { + if (tabParam && tabs.some(t => t.id === tabParam)) { + setActiveTab(tabParam); + } + }, [tabParam]); + + const handleTabChange = (tabId: TabId) => { + const tab = tabs.find(t => t.id === tabId); + if (tab?.requiresAuth && !isAuthenticated) { + showAuthModal('login'); + return; + } + setActiveTab(tabId); + router.push(`/settings?tab=${tabId}`, { scroll: false }); + }; + + const currentTab = tabs.find(t => t.id === activeTab); + const showAuthPrompt = currentTab?.requiresAuth && !isAuthenticated && !isLoading; return (
-
+
{/* Header */} -
-

Настройки

-

Персонализируйте ваш опыт

+
+

+ {activeTab === 'account' ? 'Аккаунт' : activeTab === 'billing' ? 'Оплата' : 'Настройки'} +

+

+ {activeTab === 'account' + ? 'Управление профилем и безопасностью' + : activeTab === 'billing' + ? 'Баланс, тарифы и история платежей' + : 'Персонализируйте ваш опыт'} +

-
- {/* Default Mode */} -
-
- {[ - { id: 'speed', label: 'Быстрый', icon: Zap, desc: '~10 сек' }, - { id: 'balanced', label: 'Баланс', icon: Scale, desc: '~30 сек' }, - { id: 'quality', label: 'Качество', icon: Sparkles, desc: '~60 сек' }, - ].map((m) => ( - - ))} -
-
+ {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
- {/* Privacy & Data */} -
-
- - - -
-
- - {/* Language */} -
-
- {[ - { id: 'ru', label: 'Русский', flag: '🇷🇺' }, - { id: 'en', label: 'English', flag: '🇺🇸' }, - ].map((lang) => ( - - ))} -
-
- - {/* Connectors */} -
-
-

- Подключите источники данных и каналы уведомлений для автономных задач в Лаптопе. + {/* Content */} + + {showAuthPrompt ? ( +

+
+ +
+

Требуется авторизация

+

+ Войдите в аккаунт, чтобы получить доступ к {activeTab === 'account' ? 'настройкам профиля' : 'платежам и балансу'}

- - {showConnectors && ( - - - - )} -
-
- - {/* Data Management */} -
-
- -
-
- - {/* About */} -
-
-

GooSeek v1.0.0

-

AI-поиск нового поколения

-
-
-
+ ) : ( + <> + {activeTab === 'account' && } + {activeTab === 'billing' && } + {activeTab === 'preferences' && } + + )} +
); } - -function Section({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) { - return ( - -
- -

- {title} -

-
- {children} -
- ); -} - -interface ToggleRowProps { - icon: React.ElementType; - label: string; - description: string; - checked: boolean; - onChange: (v: boolean) => void; -} - -function ToggleRow({ icon: Icon, label, description, checked, onChange }: ToggleRowProps) { - return ( -
-
- -
-
-

{label}

-

{description}

-
- - - -
- ); -} diff --git a/backend/webui/src/app/(main)/spaces/[id]/page.tsx b/backend/webui/src/app/(main)/spaces/[id]/page.tsx new file mode 100644 index 0000000..f632b19 --- /dev/null +++ b/backend/webui/src/app/(main)/spaces/[id]/page.tsx @@ -0,0 +1,466 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + ArrowLeft, + FolderOpen, + Plus, + Users, + MessageSquare, + Settings, + UserPlus, + Loader2, + Clock, + MoreHorizontal, + Trash2, + Crown, + Shield, + User, + Copy, + Check, + X, + Send, +} from 'lucide-react'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import * as Dialog from '@radix-ui/react-dialog'; +import { fetchSpace, fetchSpaceMembers, fetchSpaceThreads, inviteToSpace, removeSpaceMember } from '@/lib/api'; +import type { Space, SpaceMember, Thread } from '@/lib/types'; +import { useAuth } from '@/lib/contexts/AuthContext'; + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return 'Сегодня'; + if (days === 1) return 'Вчера'; + if (days < 7) return `${days} дн. назад`; + + return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); +} + +function getRoleIcon(role: string) { + switch (role) { + case 'owner': return Crown; + case 'admin': return Shield; + default: return User; + } +} + +function getRoleLabel(role: string) { + switch (role) { + case 'owner': return 'Владелец'; + case 'admin': return 'Админ'; + default: return 'Участник'; + } +} + +export default function SpaceDetailPage() { + const params = useParams(); + const router = useRouter(); + const { user } = useAuth(); + const spaceId = params.id as string; + + const [space, setSpace] = useState(null); + const [members, setMembers] = useState([]); + const [threads, setThreads] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [activeTab, setActiveTab] = useState<'threads' | 'members'>('threads'); + const [showInviteModal, setShowInviteModal] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteLoading, setInviteLoading] = useState(false); + const [inviteError, setInviteError] = useState(''); + const [inviteSuccess, setInviteSuccess] = useState(''); + + const isOwner = space?.userId === user?.id; + const currentMember = members.find(m => m.userId === user?.id); + const isAdmin = isOwner || currentMember?.role === 'admin'; + + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const [spaceData, membersData, threadsData] = await Promise.all([ + fetchSpace(spaceId), + fetchSpaceMembers(spaceId), + fetchSpaceThreads(spaceId), + ]); + setSpace(spaceData); + setMembers(membersData); + setThreads(threadsData); + } catch (err) { + console.error('Failed to load space:', err); + } finally { + setIsLoading(false); + } + }, [spaceId]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + if (!inviteEmail.trim() || inviteLoading) return; + + setInviteLoading(true); + setInviteError(''); + setInviteSuccess(''); + + try { + await inviteToSpace(spaceId, inviteEmail.trim()); + setInviteSuccess(`Приглашение отправлено на ${inviteEmail}`); + setInviteEmail(''); + setTimeout(() => setShowInviteModal(false), 2000); + } catch (err) { + setInviteError(err instanceof Error ? err.message : 'Не удалось отправить приглашение'); + } finally { + setInviteLoading(false); + } + }; + + const handleRemoveMember = async (memberId: string, userId: string) => { + if (!confirm('Удалить участника из пространства?')) return; + + try { + await removeSpaceMember(spaceId, userId); + setMembers(prev => prev.filter(m => m.id !== memberId)); + } catch (err) { + console.error('Failed to remove member:', err); + } + }; + + const startNewThread = () => { + router.push(`/?space=${spaceId}`); + }; + + if (isLoading) { + return ( +
+ +

Загрузка пространства...

+
+ ); + } + + if (!space) { + return ( +
+ +

Пространство не найдено

+

Возможно, оно было удалено или у вас нет доступа

+ + К списку пространств + +
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + + + +
+
+
+

{space.name}

+ {space.description && ( +

{space.description}

+ )} +
+ +
+ {isAdmin && ( + + )} + + + +
+
+ + {/* Stats */} +
+
+ + {members.length} участник{members.length === 1 ? '' : members.length < 5 ? 'а' : 'ов'} +
+
+ + {threads.length} тред{threads.length === 1 ? '' : threads.length < 5 ? 'а' : 'ов'} +
+
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Content */} + + {activeTab === 'threads' ? ( + + {/* New Thread Button */} + + + {/* Threads List */} + {threads.length > 0 ? ( +
+ {threads.map((thread, i) => ( + + +

+ {thread.title || 'Без названия'} +

+
+ + + {formatDate(thread.updatedAt)} + + {thread.messages?.length || 0} сообщений +
+ +
+ ))} +
+ ) : ( +
+ +

Пока нет тредов

+

Начните новый тред для обсуждения

+
+ )} +
+ ) : ( + + {/* Members List */} +
+ {members.map((member, i) => { + const RoleIcon = getRoleIcon(member.role); + const canRemove = isAdmin && member.userId !== user?.id && member.role !== 'owner'; + + return ( + +
+ {member.avatar ? ( + + ) : ( + + {(member.name || member.email || '?').charAt(0).toUpperCase()} + + )} +
+ +
+
+ + {member.name || member.email} + + + + {getRoleLabel(member.role)} + +
+ {member.email && member.name && ( +

{member.email}

+ )} +
+ + {canRemove && ( + + + + + + + handleRemoveMember(member.id, member.userId)} + className="flex items-center gap-2 px-3 py-2.5 text-sm text-error rounded-lg cursor-pointer hover:bg-error/10 outline-none transition-colors" + > + + Удалить + + + + + )} +
+ ); + })} +
+ + {/* Invite Button */} + {isAdmin && ( + + )} +
+ )} +
+ + {/* Invite Modal */} + + + + +
+ + Пригласить участника + + + + +
+ +
+

+ Введите email пользователя, которого хотите пригласить в пространство +

+ + {inviteError && ( +
+ {inviteError} +
+ )} + + {inviteSuccess && ( +
+ + {inviteSuccess} +
+ )} + +
+ setInviteEmail(e.target.value)} + placeholder="email@example.com" + className="w-full px-4 py-3 bg-surface/50 border border-border rounded-xl text-primary placeholder:text-muted focus:outline-none input-gradient transition-colors" + autoFocus + /> +
+ +
+ + + + +
+
+
+
+
+
+
+ ); +} diff --git a/backend/webui/src/app/(main)/spaces/new/page.tsx b/backend/webui/src/app/(main)/spaces/new/page.tsx index e12961e..d5fc36f 100644 --- a/backend/webui/src/app/(main)/spaces/new/page.tsx +++ b/backend/webui/src/app/(main)/spaces/new/page.tsx @@ -3,19 +3,28 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { ArrowLeft, Loader2, Globe, BookOpen, Code2, Newspaper, TrendingUp, Youtube } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { + ArrowLeft, + Loader2, + Globe, + Lock, + FolderOpen, + Palette, +} from 'lucide-react'; import { createSpace } from '@/lib/api'; -import type { FocusMode } from '@/lib/types'; -const focusModes: { value: FocusMode; label: string; icon: React.ElementType }[] = [ - { value: 'all', label: 'Все источники', icon: Globe }, - { value: 'academic', label: 'Академический', icon: BookOpen }, - { value: 'code', label: 'Код', icon: Code2 }, - { value: 'news', label: 'Новости', icon: Newspaper }, - { value: 'finance', label: 'Финансы', icon: TrendingUp }, - { value: 'youtube', label: 'YouTube', icon: Youtube }, +const spaceColors = [ + { id: 'violet', class: 'bg-violet-500', label: 'Фиолетовый' }, + { id: 'blue', class: 'bg-blue-500', label: 'Синий' }, + { id: 'emerald', class: 'bg-emerald-500', label: 'Изумрудный' }, + { id: 'orange', class: 'bg-orange-500', label: 'Оранжевый' }, + { id: 'pink', class: 'bg-pink-500', label: 'Розовый' }, + { id: 'indigo', class: 'bg-indigo-500', label: 'Индиго' }, ]; +const spaceIcons = ['📁', '🔬', '💡', '📊', '🎯', '🚀', '💼', '📚']; + export default function NewSpacePage() { const router = useRouter(); const [isSubmitting, setIsSubmitting] = useState(false); @@ -25,7 +34,9 @@ export default function NewSpacePage() { name: '', description: '', instructions: '', - focusMode: 'all' as FocusMode, + icon: '📁', + color: 'violet', + isPublic: false, }); const handleCreate = async (e: React.FormEvent) => { @@ -36,13 +47,12 @@ export default function NewSpacePage() { setError(null); try { - await createSpace({ + const space = await createSpace({ name: formData.name.trim(), description: formData.description.trim() || undefined, instructions: formData.instructions.trim() || undefined, - focusMode: formData.focusMode, }); - router.push('/spaces'); + router.push(`/spaces/${space.id}`); } catch (err) { console.error('Failed to create space:', err); setError('Не удалось создать пространство. Попробуйте позже.'); @@ -53,9 +63,9 @@ export default function NewSpacePage() { return (
-
+
{/* Header */} -
+
-

Новое пространство

-

Организуйте исследования по темам

+

Новое пространство

+

Создайте место для исследований и коллаборации

{error && ( -
+ {error} -
+ )} {/* Form */} -
+ + {/* Preview Card */} + +
+
+ {formData.icon} +
+
+

+ {formData.name || 'Название пространства'} +

+

+ {formData.description || 'Описание пространства'} +

+
+
+
+ + {/* Name */}
+ {/* Description */}