diff --git a/alembic/versions/0019_user_privacy_fields.py b/alembic/versions/0019_user_privacy_fields.py new file mode 100644 index 0000000..04a3dfc --- /dev/null +++ b/alembic/versions/0019_user_privacy_fields.py @@ -0,0 +1,39 @@ +"""add user privacy fields + +Revision ID: 0019_user_privacy_fields +Revises: 0018_user_twofa +Create Date: 2026-03-08 23:59:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0019_user_privacy_fields" +down_revision: Union[str, Sequence[str], None] = "0018_user_twofa" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("privacy_last_seen", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")), + ) + op.add_column( + "users", + sa.Column("privacy_avatar", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")), + ) + op.add_column( + "users", + sa.Column("privacy_group_invites", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")), + ) + + +def downgrade() -> None: + op.drop_column("users", "privacy_group_invites") + op.drop_column("users", "privacy_avatar") + op.drop_column("users", "privacy_last_seen") + diff --git a/app/auth/router.py b/app/auth/router.py index eb3f194..82ed13e 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.schemas import ( AuthUserResponse, + EmailStatusResponse, LoginRequest, MessageResponse, RefreshTokenRequest, @@ -30,6 +31,7 @@ from app.auth.service import ( revoke_user_session, refresh_tokens, register_user, + get_email_status, request_password_reset, resend_verification_email, reset_password, @@ -45,6 +47,14 @@ from app.users.models import User router = APIRouter(prefix="/auth", tags=["auth"]) +@router.get("/check-email", response_model=EmailStatusResponse) +async def check_email_status( + email: str, + db: AsyncSession = Depends(get_db), +) -> EmailStatusResponse: + return await get_email_status(db, email=email) + + @router.post("/register", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) async def register( payload: RegisterRequest, diff --git a/app/auth/schemas.py b/app/auth/schemas.py index fac71a3..7781f6e 100644 --- a/app/auth/schemas.py +++ b/app/auth/schemas.py @@ -1,6 +1,7 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, EmailStr, Field +from app.users.schemas import GroupInvitePrivacyLevel, PrivacyLevel class RegisterRequest(BaseModel): @@ -58,6 +59,10 @@ class AuthUserResponse(BaseModel): avatar_url: str | None = None email_verified: bool twofa_enabled: bool + allow_private_messages: bool = True + privacy_last_seen: PrivacyLevel = "everyone" + privacy_avatar: PrivacyLevel = "everyone" + privacy_group_invites: GroupInvitePrivacyLevel = "everyone" created_at: datetime updated_at: datetime @@ -76,3 +81,10 @@ class TwoFactorSetupRead(BaseModel): class TwoFactorCodeRequest(BaseModel): code: str = Field(min_length=6, max_length=8) + + +class EmailStatusResponse(BaseModel): + email: EmailStr + registered: bool + email_verified: bool = False + twofa_enabled: bool = False diff --git a/app/auth/service.py b/app/auth/service.py index 341973d..750b9ea 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -16,6 +16,7 @@ from app.auth.token_store import ( store_refresh_token_jti, ) from app.auth.schemas import ( + EmailStatusResponse, LoginRequest, RefreshTokenRequest, RegisterRequest, @@ -343,3 +344,15 @@ async def get_current_user_for_ws(token: str, db: AsyncSession) -> User: def get_email_sender() -> EmailService: return get_email_service() + + +async def get_email_status(db: AsyncSession, email: str) -> EmailStatusResponse: + user = await get_user_by_email(db, email) + if not user: + return EmailStatusResponse(email=email, registered=False) + return EmailStatusResponse( + email=email, + registered=True, + email_verified=user.email_verified, + twofa_enabled=user.twofa_enabled, + ) diff --git a/app/users/models.py b/app/users/models.py index feb5673..e683dac 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -24,6 +24,9 @@ class User(Base): bio: Mapped[str | None] = mapped_column(String(500), nullable=True) email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) allow_private_messages: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, server_default="true") + privacy_last_seen: Mapped[str] = mapped_column(String(16), nullable=False, default="everyone", server_default="everyone") + privacy_avatar: Mapped[str] = mapped_column(String(16), nullable=False, default="everyone", server_default="everyone") + privacy_group_invites: Mapped[str] = mapped_column(String(16), nullable=False, default="everyone", server_default="everyone") twofa_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false") twofa_secret: Mapped[str | None] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/app/users/router.py b/app/users/router.py index ba33576..7dcfb37 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -65,6 +65,9 @@ async def update_profile( bio=payload.bio, avatar_url=payload.avatar_url, allow_private_messages=payload.allow_private_messages, + privacy_last_seen=payload.privacy_last_seen, + privacy_avatar=payload.privacy_avatar, + privacy_group_invites=payload.privacy_group_invites, ) return updated diff --git a/app/users/schemas.py b/app/users/schemas.py index c3ef7c3..cbad25c 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -1,6 +1,11 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, EmailStr, Field +from typing import Literal + + +PrivacyLevel = Literal["everyone", "contacts", "nobody"] +GroupInvitePrivacyLevel = Literal["everyone", "contacts"] class UserBase(BaseModel): @@ -21,6 +26,9 @@ class UserRead(UserBase): bio: str | None = None email_verified: bool allow_private_messages: bool + privacy_last_seen: PrivacyLevel = "everyone" + privacy_avatar: PrivacyLevel = "everyone" + privacy_group_invites: GroupInvitePrivacyLevel = "everyone" twofa_enabled: bool = False created_at: datetime updated_at: datetime @@ -32,6 +40,9 @@ class UserProfileUpdate(BaseModel): bio: str | None = Field(default=None, max_length=500) avatar_url: str | None = Field(default=None, max_length=512) allow_private_messages: bool | None = None + privacy_last_seen: PrivacyLevel | None = None + privacy_avatar: PrivacyLevel | None = None + privacy_group_invites: GroupInvitePrivacyLevel | None = None class UserSearchRead(BaseModel): diff --git a/app/users/service.py b/app/users/service.py index 44907d2..aacce1a 100644 --- a/app/users/service.py +++ b/app/users/service.py @@ -41,6 +41,9 @@ async def update_user_profile( bio: str | None = None, avatar_url: str | None = None, allow_private_messages: bool | None = None, + privacy_last_seen: str | None = None, + privacy_avatar: str | None = None, + privacy_group_invites: str | None = None, ) -> User: if name is not None: user.name = name @@ -52,6 +55,12 @@ async def update_user_profile( user.avatar_url = avatar_url if allow_private_messages is not None: user.allow_private_messages = allow_private_messages + if privacy_last_seen is not None: + user.privacy_last_seen = privacy_last_seen + if privacy_avatar is not None: + user.privacy_avatar = privacy_avatar + if privacy_group_invites is not None: + user.privacy_group_invites = privacy_group_invites await db.commit() await db.refresh(user) return user diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..68690b8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,23 @@ +# API Documentation + +This folder contains full backend API documentation for `BenyaMessenger`. + +- Main REST reference: [api-reference.md](./api-reference.md) +- Realtime/WebSocket reference: [realtime.md](./realtime.md) + +Base URL: + +- Local: `http://localhost:8000` +- Prefix for API v1: `/api/v1` +- Full base path for REST endpoints: `/api/v1/...` + +Built-in health endpoints: + +- `GET /health` +- `GET /health/live` +- `GET /health/ready` + +Auth for protected REST endpoints: + +- Header: `Authorization: Bearer ` + diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..d29619f --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,938 @@ +# REST API Reference + +## 1. Conventions + +Base path: `/api/v1` + +Authentication: + +- Use JWT access token in header: `Authorization: Bearer ` +- Login/refresh endpoints return `{ access_token, refresh_token, token_type }` + +Response codes: + +- `200` success +- `201` created +- `204` success, no body +- `400` bad request +- `401` unauthorized +- `403` forbidden +- `404` not found +- `409` conflict +- `422` validation/business rule error +- `429` rate limit +- `503` external service unavailable (email/storage/readiness) + +Common error body: + +```json +{ + "detail": "Error text" +} +``` + +For `/health/ready` failure: + +```json +{ + "detail": { + "status": "not_ready", + "db": false, + "redis": true + } +} +``` + +## 2. Enums + +### ChatType + +- `private` +- `group` +- `channel` + +### ChatMemberRole + +- `owner` +- `admin` +- `member` + +### MessageType + +- `text` +- `image` +- `video` +- `audio` +- `voice` +- `file` +- `circle_video` + +### Message status events + +- `message_delivered` +- `message_read` + +## 3. Models (request/response) + +## 3.1 Auth + +### RegisterRequest + +```json +{ + "email": "user@example.com", + "name": "Benya", + "username": "benya", + "password": "strongpassword" +} +``` + +### LoginRequest + +```json +{ + "email": "user@example.com", + "password": "strongpassword", + "otp_code": "123456" +} +``` + +`otp_code` is optional and used only when 2FA is enabled. + +### TokenResponse + +```json +{ + "access_token": "jwt", + "refresh_token": "jwt", + "token_type": "bearer" +} +``` + +### AuthUserResponse + +```json +{ + "id": 1, + "email": "user@example.com", + "name": "Benya", + "username": "benya", + "bio": "optional", + "avatar_url": "https://...", + "email_verified": true, + "twofa_enabled": false, + "created_at": "2026-03-08T10:00:00Z", + "updated_at": "2026-03-08T10:00:00Z" +} +``` + +## 3.2 Users + +### UserRead + +```json +{ + "id": 1, + "name": "Benya", + "username": "benya", + "email": "user@example.com", + "avatar_url": "https://...", + "bio": "optional", + "email_verified": true, + "allow_private_messages": true, + "twofa_enabled": false, + "created_at": "2026-03-08T10:00:00Z", + "updated_at": "2026-03-08T10:00:00Z" +} +``` + +### UserSearchRead + +```json +{ + "id": 2, + "name": "Other User", + "username": "other", + "email": "other@example.com", + "avatar_url": null +} +``` + +### UserProfileUpdate + +```json +{ + "name": "New Name", + "username": "new_username", + "bio": "new bio", + "avatar_url": "https://...", + "allow_private_messages": true +} +``` + +All fields are optional. + +## 3.3 Chats + +### ChatRead + +```json +{ + "id": 10, + "public_id": "A1B2C3D4E5F6G7H8J9K0L1M2", + "type": "private", + "title": null, + "display_title": "Other User", + "handle": null, + "description": null, + "is_public": false, + "is_saved": false, + "archived": false, + "pinned": false, + "unread_count": 3, + "pinned_message_id": null, + "members_count": 2, + "online_count": 1, + "subscribers_count": null, + "counterpart_user_id": 2, + "counterpart_name": "Other User", + "counterpart_username": "other", + "counterpart_is_online": true, + "counterpart_last_seen_at": "2026-03-08T10:00:00Z", + "last_message_text": "Hello", + "last_message_type": "text", + "last_message_created_at": "2026-03-08T10:01:00Z", + "my_role": "member", + "created_at": "2026-03-08T09:00:00Z" +} +``` + +### ChatCreateRequest + +```json +{ + "type": "group", + "title": "My Group", + "handle": "mygroup", + "description": "optional", + "is_public": true, + "member_ids": [2, 3] +} +``` + +Rules: + +- `private`: requires exactly 1 target in `member_ids` +- `group`/`channel`: require `title` +- `private` cannot be public +- public chat requires `handle` + +### ChatDetailRead + +`ChatRead + members[]`, where member item is: + +```json +{ + "id": 1, + "user_id": 2, + "role": "member", + "joined_at": "2026-03-08T09:00:00Z" +} +``` + +## 3.4 Messages + +### MessageRead + +```json +{ + "id": 100, + "chat_id": 10, + "sender_id": 1, + "reply_to_message_id": null, + "forwarded_from_message_id": null, + "type": "text", + "text": "Hello", + "created_at": "2026-03-08T10:02:00Z", + "updated_at": "2026-03-08T10:02:00Z" +} +``` + +### MessageCreateRequest + +```json +{ + "chat_id": 10, + "type": "text", + "text": "Hello", + "client_message_id": "client-msg-0001", + "reply_to_message_id": null +} +``` + +### MessageForwardRequest + +```json +{ + "target_chat_id": 20 +} +``` + +### MessageForwardBulkRequest + +```json +{ + "target_chat_ids": [20, 21] +} +``` + +### MessageStatusUpdateRequest + +```json +{ + "chat_id": 10, + "message_id": 100, + "status": "message_read" +} +``` + +### MessageReactionToggleRequest + +```json +{ + "emoji": "👍" +} +``` + +## 3.5 Media + +### UploadUrlRequest + +```json +{ + "file_name": "photo.jpg", + "file_type": "image/jpeg", + "file_size": 123456 +} +``` + +### UploadUrlResponse + +```json +{ + "upload_url": "https://...signed...", + "file_url": "https://.../bucket/uploads/....jpg", + "object_key": "uploads/....jpg", + "expires_in": 900, + "required_headers": { + "Content-Type": "image/jpeg" + } +} +``` + +### AttachmentCreateRequest + +```json +{ + "message_id": 100, + "file_url": "https://.../bucket/uploads/....jpg", + "file_type": "image/jpeg", + "file_size": 123456 +} +``` + +### AttachmentRead + +```json +{ + "id": 1, + "message_id": 100, + "file_url": "https://...", + "file_type": "image/jpeg", + "file_size": 123456 +} +``` + +### ChatAttachmentRead + +```json +{ + "id": 1, + "message_id": 100, + "sender_id": 1, + "message_type": "image", + "message_created_at": "2026-03-08T10:10:00Z", + "file_url": "https://...", + "file_type": "image/jpeg", + "file_size": 123456 +} +``` + +## 3.6 Search and notifications + +### GlobalSearchRead + +```json +{ + "users": [], + "chats": [], + "messages": [] +} +``` + +### NotificationRead + +```json +{ + "id": 1, + "user_id": 1, + "event_type": "message_created", + "payload": "{\"chat_id\":10}", + "created_at": "2026-03-08T10:15:00Z" +} +``` + +## 4. Health endpoints + +### GET `/health` + +Returns: + +```json +{ "status": "ok" } +``` + +### GET `/health/live` + +Returns: + +```json +{ "status": "ok" } +``` + +### GET `/health/ready` + +Returns: + +```json +{ "status": "ready", "db": "ok", "redis": "ok" } +``` + +## 5. Auth endpoints + +### POST `/api/v1/auth/register` + +Body: `RegisterRequest` +Response: `201` + `MessageResponse` + +### POST `/api/v1/auth/login` + +Body: `LoginRequest` +Response: `200` + `TokenResponse` + +### POST `/api/v1/auth/refresh` + +Body: + +```json +{ "refresh_token": "jwt" } +``` + +Response: `200` + `TokenResponse` + +### POST `/api/v1/auth/verify-email` + +Body: + +```json +{ "token": "verification_token" } +``` + +Response: `200` + `MessageResponse` + +### POST `/api/v1/auth/resend-verification` + +Body: + +```json +{ "email": "user@example.com" } +``` + +Response: `200` + `MessageResponse` + +### POST `/api/v1/auth/request-password-reset` + +Body: + +```json +{ "email": "user@example.com" } +``` + +Response: `200` + `MessageResponse` + +### POST `/api/v1/auth/reset-password` + +Body: + +```json +{ + "token": "reset_token", + "new_password": "newStrongPassword" +} +``` + +Response: `200` + `MessageResponse` + +### GET `/api/v1/auth/me` + +Auth required. +Response: `200` + `AuthUserResponse` + +### GET `/api/v1/auth/sessions` + +Auth required. +Response: `200` + `SessionRead[]` + +### DELETE `/api/v1/auth/sessions/{jti}` + +Auth required. +Response: `204` + +### DELETE `/api/v1/auth/sessions` + +Auth required. +Response: `204` + +### POST `/api/v1/auth/2fa/setup` + +Auth required. +Response: + +```json +{ + "secret": "BASE32SECRET", + "otpauth_url": "otpauth://..." +} +``` + +### POST `/api/v1/auth/2fa/enable` + +Auth required. +Body: + +```json +{ "code": "123456" } +``` + +Response: `200` + `MessageResponse` + +### POST `/api/v1/auth/2fa/disable` + +Auth required. +Body: + +```json +{ "code": "123456" } +``` + +Response: `200` + `MessageResponse` + +## 6. Users endpoints + +### GET `/api/v1/users/me` + +Auth required. +Response: `200` + `UserRead` + +### GET `/api/v1/users/search?query=&limit=20` + +Auth required. +Response: `200` + `UserSearchRead[]` +Note: if query shorter than 2 chars (after trimming optional leading `@`) returns empty list. + +### PUT `/api/v1/users/profile` + +Auth required. +Body: `UserProfileUpdate` +Response: `200` + `UserRead` + +### GET `/api/v1/users/blocked` + +Auth required. +Response: `200` + `UserSearchRead[]` + +### GET `/api/v1/users/contacts` + +Auth required. +Response: `200` + `UserSearchRead[]` + +### POST `/api/v1/users/{user_id}/contacts` + +Auth required. +Response: `204` + +### POST `/api/v1/users/contacts/by-email` + +Auth required. +Body: + +```json +{ "email": "target@example.com" } +``` + +Response: `204` + +### DELETE `/api/v1/users/{user_id}/contacts` + +Auth required. +Response: `204` + +### POST `/api/v1/users/{user_id}/block` + +Auth required. +Response: `204` + +### DELETE `/api/v1/users/{user_id}/block` + +Auth required. +Response: `204` + +### GET `/api/v1/users/{user_id}` + +Auth required. +Response: `200` + `UserRead` + +## 7. Chats endpoints + +### GET `/api/v1/chats` + +Query params: + +- `limit` (default `50`) +- `before_id` (optional) +- `query` (optional search by title/type) +- `archived` (default `false`) + +Auth required. +Response: `200` + `ChatRead[]` + +### GET `/api/v1/chats/saved` + +Returns or creates personal saved-messages chat. + +Auth required. +Response: `200` + `ChatRead` + +### GET `/api/v1/chats/discover?query=&limit=30` + +Auth required. +Response: `200` + `ChatDiscoverRead[]` + +### POST `/api/v1/chats` + +Auth required. +Body: `ChatCreateRequest` +Response: `200` + `ChatRead` + +### POST `/api/v1/chats/{chat_id}/join` + +Join public group/channel. + +Auth required. +Response: `200` + `ChatRead` + +### GET `/api/v1/chats/{chat_id}` + +Auth required. +Response: `200` + `ChatDetailRead` + +### PATCH `/api/v1/chats/{chat_id}/title` + +Auth required. +Body: + +```json +{ "title": "New title" } +``` + +Response: `200` + `ChatRead` + +### GET `/api/v1/chats/{chat_id}/members` + +Auth required. +Response: `200` + `ChatMemberRead[]` + +### POST `/api/v1/chats/{chat_id}/members` + +Auth required. +Body: + +```json +{ "user_id": 123 } +``` + +Response: `201` + `ChatMemberRead` + +### PATCH `/api/v1/chats/{chat_id}/members/{user_id}/role` + +Auth required. +Body: + +```json +{ "role": "admin" } +``` + +Response: `200` + `ChatMemberRead` + +### DELETE `/api/v1/chats/{chat_id}/members/{user_id}` + +Auth required. +Response: `204` + +### POST `/api/v1/chats/{chat_id}/leave` + +Auth required. +Response: `204` + +### DELETE `/api/v1/chats/{chat_id}?for_all=false` + +Auth required. +Response: `204` + +### POST `/api/v1/chats/{chat_id}/clear` + +Clear chat history for current user. + +Auth required. +Response: `204` + +### POST `/api/v1/chats/{chat_id}/pin` + +Pin chat message. + +Body: + +```json +{ "message_id": 100 } +``` + +or unpin: + +```json +{ "message_id": null } +``` + +Response: `200` + `ChatRead` + +### GET `/api/v1/chats/{chat_id}/notifications` + +Auth required. +Response: + +```json +{ + "chat_id": 10, + "user_id": 1, + "muted": false +} +``` + +### PUT `/api/v1/chats/{chat_id}/notifications` + +Auth required. +Body: + +```json +{ "muted": true } +``` + +Response: `200` + `ChatNotificationSettingsRead` + +### POST `/api/v1/chats/{chat_id}/archive` + +Auth required. +Response: `200` + `ChatRead` + +### POST `/api/v1/chats/{chat_id}/unarchive` + +Auth required. +Response: `200` + `ChatRead` + +### POST `/api/v1/chats/{chat_id}/pin-chat` + +Auth required. +Response: `200` + `ChatRead` + +### POST `/api/v1/chats/{chat_id}/unpin-chat` + +Auth required. +Response: `200` + `ChatRead` + +### POST `/api/v1/chats/{chat_id}/invite-link` + +Auth required. +Response: + +```json +{ + "chat_id": 10, + "token": "invite_token", + "invite_url": "https://frontend/join?token=invite_token" +} +``` + +### POST `/api/v1/chats/join-by-invite` + +Auth required. +Body: + +```json +{ "token": "invite_token" } +``` + +Response: `200` + `ChatRead` + +## 8. Messages endpoints + +### POST `/api/v1/messages` + +Auth required. +Body: `MessageCreateRequest` +Response: `201` + `MessageRead` + +### GET `/api/v1/messages/search?query=&chat_id=&limit=50` + +Auth required. +Response: `200` + `MessageRead[]` + +### GET `/api/v1/messages/{chat_id}?limit=50&before_id=` + +Auth required. +Response: `200` + `MessageRead[]` + +### PUT `/api/v1/messages/{message_id}` + +Auth required. +Body: + +```json +{ "text": "Edited text" } +``` + +Response: `200` + `MessageRead` + +### DELETE `/api/v1/messages/{message_id}?for_all=false` + +Auth required. +Response: `204` + +### POST `/api/v1/messages/status` + +Auth required. +Body: `MessageStatusUpdateRequest` +Response: + +```json +{ + "last_delivered_message_id": 123, + "last_read_message_id": 120 +} +``` + +### POST `/api/v1/messages/{message_id}/forward` + +Auth required. +Body: `MessageForwardRequest` +Response: `201` + `MessageRead` + +### POST `/api/v1/messages/{message_id}/forward-bulk` + +Auth required. +Body: `MessageForwardBulkRequest` +Response: `201` + `MessageRead[]` + +### GET `/api/v1/messages/{message_id}/reactions` + +Auth required. +Response: + +```json +[ + { "emoji": "👍", "count": 3, "reacted": true } +] +``` + +### POST `/api/v1/messages/{message_id}/reactions/toggle` + +Auth required. +Body: `MessageReactionToggleRequest` +Response: `200` + `MessageReactionRead[]` + +## 9. Media endpoints + +### POST `/api/v1/media/upload-url` + +Auth required. +Body: `UploadUrlRequest` +Response: `200` + `UploadUrlResponse` + +Validation: + +- Allowed MIME: `image/jpeg`, `image/png`, `image/webp`, `video/mp4`, `video/webm`, `audio/mpeg`, `audio/ogg`, `audio/webm`, `audio/wav`, `application/pdf`, `application/zip`, `text/plain` +- Max size: `MAX_UPLOAD_SIZE_BYTES` + +### POST `/api/v1/media/attachments` + +Auth required. +Body: `AttachmentCreateRequest` +Response: `200` + `AttachmentRead` + +Rules: + +- `file_url` must point to configured S3 bucket endpoint +- Only message sender can attach files + +### GET `/api/v1/media/chats/{chat_id}/attachments?limit=100&before_id=` + +Auth required. +Response: `200` + `ChatAttachmentRead[]` + +## 10. Notifications + +### GET `/api/v1/notifications?limit=50` + +Auth required. +Response: `200` + `NotificationRead[]` + +## 11. Global search + +### GET `/api/v1/search?query=&users_limit=10&chats_limit=10&messages_limit=10` + +Auth required. +Response: `200` + `GlobalSearchRead` + +## 12. Rate limits + +Configured via env vars: + +- `LOGIN_RATE_LIMIT_PER_MINUTE` for `/auth/login` +- `REGISTER_RATE_LIMIT_PER_MINUTE` for `/auth/register` +- `REFRESH_RATE_LIMIT_PER_MINUTE` for `/auth/refresh` +- `RESET_RATE_LIMIT_PER_MINUTE` for reset/resend flows +- `MESSAGE_RATE_LIMIT_PER_MINUTE` and `DUPLICATE_MESSAGE_COOLDOWN_SECONDS` for sending messages + +429 response example: + +```json +{ + "detail": "Rate limit exceeded. Retry in 34 seconds." +} +``` + +## 13. Notes + +- `public_id` is returned for chats and should be used by clients as stable public identifier. +- Invite links are generated for group/channel chats. +- In channels, only users with sufficient role (owner/admin) can post. +- `email` router exists in codebase but has no public REST endpoints yet. + diff --git a/docs/realtime.md b/docs/realtime.md new file mode 100644 index 0000000..645de3f --- /dev/null +++ b/docs/realtime.md @@ -0,0 +1,240 @@ +# Realtime WebSocket API + +WebSocket endpoint: + +- `GET /api/v1/realtime/ws?token=` + +Authentication: + +- Pass a valid **access token** as query parameter `token`. +- If token is missing/invalid, server closes with `1008` (policy violation). + +## 1. Message envelope + +All incoming and outgoing events use the same envelope: + +```json +{ + "event": "event_name", + "payload": {}, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +`timestamp` is present in outgoing events from the server. + +## 2. Incoming events (client -> server) + +## 2.1 `ping` + +```json +{ + "event": "ping", + "payload": {} +} +``` + +Server response: `pong` + +## 2.2 `send_message` + +```json +{ + "event": "send_message", + "payload": { + "chat_id": 10, + "type": "text", + "text": "Hello", + "temp_id": "tmp-1", + "client_message_id": "client-msg-1", + "reply_to_message_id": null + } +} +``` + +Supported `type` values: + +- `text`, `image`, `video`, `audio`, `voice`, `file`, `circle_video` + +## 2.3 `typing_start` / `typing_stop` + +```json +{ + "event": "typing_start", + "payload": { + "chat_id": 10 + } +} +``` + +## 2.4 `message_delivered` / `message_read` + +```json +{ + "event": "message_read", + "payload": { + "chat_id": 10, + "message_id": 150 + } +} +``` + +## 3. Outgoing events (server -> client) + +## 3.1 `connect` + +Sent after successful websocket registration: + +```json +{ + "event": "connect", + "payload": { + "connection_id": "uuid" + }, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +## 3.2 `pong` + +Response to `ping`: + +```json +{ + "event": "pong", + "payload": {}, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +## 3.3 `receive_message` + +```json +{ + "event": "receive_message", + "payload": { + "chat_id": 10, + "message": { + "id": 123, + "chat_id": 10, + "sender_id": 1, + "reply_to_message_id": null, + "forwarded_from_message_id": null, + "type": "text", + "text": "Hello", + "created_at": "2026-03-08T12:00:00Z", + "updated_at": "2026-03-08T12:00:00Z" + }, + "temp_id": "tmp-1", + "client_message_id": "client-msg-1", + "sender_id": 1 + }, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +## 3.4 Typing events + +`typing_start` and `typing_stop`: + +```json +{ + "event": "typing_start", + "payload": { + "chat_id": 10, + "user_id": 2 + }, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +## 3.5 Delivery/read events + +`message_delivered` and `message_read`: + +```json +{ + "event": "message_read", + "payload": { + "chat_id": 10, + "message_id": 150, + "user_id": 2, + "last_delivered_message_id": 150, + "last_read_message_id": 150 + }, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +## 3.6 Presence events + +### `user_online` + +```json +{ + "event": "user_online", + "payload": { + "chat_id": 10, + "user_id": 2, + "is_online": true + }, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +### `user_offline` + +```json +{ + "event": "user_offline", + "payload": { + "chat_id": 10, + "user_id": 2, + "is_online": false, + "last_seen_at": "2026-03-08T12:00:00Z" + }, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +## 3.7 `chat_updated` + +Sent when chat metadata/membership/roles/title changes: + +```json +{ + "event": "chat_updated", + "payload": { + "chat_id": 10 + }, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +## 3.8 `error` + +Validation/runtime error during WS processing: + +```json +{ + "event": "error", + "payload": { + "detail": "Invalid event payload" + }, + "timestamp": "2026-03-08T12:00:00Z" +} +``` + +## 4. Disconnect behavior + +- On disconnect, server unregisters connection. +- When last connection for a user closes, server marks user offline and sends `user_offline` to related chats. + +## 5. Practical client recommendations + +- Keep one active socket per tab/session. +- Send periodic `ping` and expect `pong`. +- Reconnect with exponential backoff. +- On `chat_updated`, refresh chat metadata via REST (`GET /api/v1/chats` or `GET /api/v1/chats/{chat_id}`). +- Use REST message history endpoints for pagination; WS is realtime transport, not history source. + diff --git a/web/package-lock.json b/web/package-lock.json index b64d4ca..8b2385f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "dependencies": { "axios": "1.11.0", + "qrcode": "^1.5.4", "react": "18.3.1", "react-dom": "18.3.1", "zustand": "5.0.8" }, "devDependencies": { + "@types/qrcode": "^1.5.5", "@types/react": "18.3.24", "@types/react-dom": "18.3.7", "@vitejs/plugin-react": "5.0.2", @@ -1258,6 +1260,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1265,6 +1277,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.24", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", @@ -1307,6 +1329,30 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1476,6 +1522,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1545,6 +1600,35 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1612,6 +1696,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1628,6 +1721,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -1656,6 +1755,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1806,6 +1911,19 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1890,6 +2008,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2030,6 +2157,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2115,6 +2251,18 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2276,6 +2424,51 @@ "node": ">= 6" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2323,6 +2516,15 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2485,6 +2687,23 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2564,6 +2783,21 @@ "node": ">=8.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2684,6 +2918,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2694,6 +2934,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2873,6 +3139,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3017,6 +3290,32 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3040,6 +3339,41 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zustand": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", diff --git a/web/package.json b/web/package.json index bdffa6b..276883d 100644 --- a/web/package.json +++ b/web/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "axios": "1.11.0", + "qrcode": "^1.5.4", "react": "18.3.1", "react-dom": "18.3.1", "zustand": "5.0.8" }, "devDependencies": { + "@types/qrcode": "^1.5.5", "@types/react": "18.3.24", "@types/react-dom": "18.3.7", "@vitejs/plugin-react": "5.0.2", diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index f6d2b82..e75fe74 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -33,6 +33,18 @@ export async function revokeAllSessions(): Promise { await http.delete("/auth/sessions"); } +export interface EmailStatusResponse { + email: string; + registered: boolean; + email_verified: boolean; + twofa_enabled: boolean; +} + +export async function checkEmailStatus(email: string): Promise { + const { data } = await http.get("/auth/check-email", { params: { email } }); + return data; +} + export interface TwoFactorSetupResponse { secret: string; otpauth_url: string; diff --git a/web/src/api/users.ts b/web/src/api/users.ts index 05414d3..cdda4d6 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -14,6 +14,9 @@ interface UserProfileUpdatePayload { bio?: string | null; avatar_url?: string | null; allow_private_messages?: boolean; + privacy_last_seen?: "everyone" | "contacts" | "nobody"; + privacy_avatar?: "everyone" | "contacts" | "nobody"; + privacy_group_invites?: "everyone" | "contacts"; } export async function updateMyProfile(payload: UserProfileUpdatePayload): Promise { diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index f2cdd92..92bed0a 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -79,6 +79,9 @@ export interface AuthUser { email_verified: boolean; twofa_enabled?: boolean; allow_private_messages: boolean; + privacy_last_seen?: "everyone" | "contacts" | "nobody"; + privacy_avatar?: "everyone" | "contacts" | "nobody"; + privacy_group_invites?: "everyone" | "contacts"; created_at: string; updated_at: string; } diff --git a/web/src/components/AuthPanel.tsx b/web/src/components/AuthPanel.tsx index d71bafe..1cd29f3 100644 --- a/web/src/components/AuthPanel.tsx +++ b/web/src/components/AuthPanel.tsx @@ -1,18 +1,20 @@ +import axios from "axios"; import { FormEvent, useState } from "react"; -import { registerRequest } from "../api/auth"; +import { checkEmailStatus, registerRequest } from "../api/auth"; import { useAuthStore } from "../store/authStore"; -type Mode = "login" | "register"; +type Step = "email" | "password" | "register" | "otp"; export function AuthPanel() { const login = useAuthStore((s) => s.login); const loading = useAuthStore((s) => s.loading); - const [mode, setMode] = useState("login"); + const [step, setStep] = useState("email"); const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [otpCode, setOtpCode] = useState(""); + const [checkingEmail, setCheckingEmail] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); @@ -21,47 +23,135 @@ export function AuthPanel() { setError(null); setSuccess(null); try { - if (mode === "register") { - await registerRequest(email, name, username, password); - setSuccess("Registered. Check email verification, then login."); - setMode("login"); + if (step === "email") { + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) { + setError("Enter email."); + return; + } + setCheckingEmail(true); + try { + const status = await checkEmailStatus(normalizedEmail); + setEmail(normalizedEmail); + setStep(status.registered ? "password" : "register"); + } finally { + setCheckingEmail(false); + } return; } + + if (step === "register") { + await registerRequest(email, name, username, password); + setSuccess("Account created. Verify email and continue login."); + setStep("password"); + return; + } + + if (step === "password") { + await login(email, password); + return; + } + await login(email, password, otpCode.trim() || undefined); - } catch { - setError("Auth request failed."); + } catch (err) { + const message = getErrorMessage(err); + if (step === "password" && message.toLowerCase().includes("2fa code required")) { + setStep("otp"); + setError(null); + setSuccess("Enter 2FA code."); + return; + } + setError(message); + setSuccess(null); } } + function resetToEmail() { + setStep("email"); + setPassword(""); + setOtpCode(""); + setName(""); + setUsername(""); + setError(null); + setSuccess(null); + } + + const submitLabel = + step === "email" + ? "Continue" + : step === "register" + ? "Create account" + : step === "password" + ? "Next" + : "Sign in"; + + const isBusy = loading || checkingEmail; + return ( -
-
- - -
+
+

+ {step === "email" ? "Sign in to BenyaMessenger" : step === "register" ? "Create account" : "Enter credentials"} +

+

+ {step === "email" + ? "Enter your email to continue" + : step === "register" + ? "This email is not registered yet. Complete registration." + : step === "password" + ? "Enter your password" + : "Two-factor authentication is enabled"} +

+
- setEmail(e.target.value)} /> - {mode === "register" && ( - <> - setName(e.target.value)} /> - setUsername(e.target.value)} /> - - )} - setPassword(e.target.value)} /> - {mode === "login" ? ( +
setEmail(e.target.value)} + /> + {step !== "email" ? ( + + ) : null} +
+ + {step === "register" ? ( + <> + setName(e.target.value)} /> + setUsername(e.target.value.replace("@", ""))} + /> + + ) : null} + + {step === "password" || step === "register" || step === "otp" ? ( + setPassword(e.target.value)} + /> + ) : null} + + {step === "otp" ? ( + setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 8))} /> ) : null} -
{error ?

{error}

: null} @@ -69,3 +159,19 @@ export function AuthPanel() {
); } + +function getErrorMessage(err: unknown): string { + if (axios.isAxiosError(err)) { + const detail = err.response?.data?.detail; + if (typeof detail === "string" && detail.trim()) { + return detail; + } + if (typeof err.message === "string" && err.message.trim()) { + return err.message; + } + } + if (err instanceof Error && err.message) { + return err.message; + } + return "Auth request failed."; +} diff --git a/web/src/components/SettingsPanel.tsx b/web/src/components/SettingsPanel.tsx index 9c38c17..25d8d67 100644 --- a/web/src/components/SettingsPanel.tsx +++ b/web/src/components/SettingsPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; +import QRCode from "qrcode"; import { listBlockedUsers, updateMyProfile } from "../api/users"; import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth"; import type { AuthSession, AuthUser } from "../chat/types"; @@ -15,6 +16,7 @@ interface Props { export function SettingsPanel({ open, onClose }: Props) { const me = useAuthStore((s) => s.me); + const logout = useAuthStore((s) => s.logout); const [page, setPage] = useState("main"); const [prefs, setPrefs] = useState(() => getAppPreferences()); const [blockedCount, setBlockedCount] = useState(0); @@ -25,7 +27,11 @@ export function SettingsPanel({ open, onClose }: Props) { const [twofaCode, setTwofaCode] = useState(""); const [twofaSecret, setTwofaSecret] = useState(null); const [twofaUrl, setTwofaUrl] = useState(null); + const [twofaQrUrl, setTwofaQrUrl] = useState(null); const [twofaError, setTwofaError] = useState(null); + const [privacyLastSeen, setPrivacyLastSeen] = useState<"everyone" | "contacts" | "nobody">("everyone"); + const [privacyAvatar, setPrivacyAvatar] = useState<"everyone" | "contacts" | "nobody">("everyone"); + const [privacyGroupInvites, setPrivacyGroupInvites] = useState<"everyone" | "contacts">("everyone"); const [profileDraft, setProfileDraft] = useState({ name: "", username: "", @@ -38,6 +44,9 @@ export function SettingsPanel({ open, onClose }: Props) { return; } setAllowPrivateMessages(me.allow_private_messages); + setPrivacyLastSeen(me.privacy_last_seen || "everyone"); + setPrivacyAvatar(me.privacy_avatar || "everyone"); + setPrivacyGroupInvites(me.privacy_group_invites || "everyone"); setProfileDraft({ name: me.name || "", username: me.username || "", @@ -46,6 +55,29 @@ export function SettingsPanel({ open, onClose }: Props) { }); }, [me]); + useEffect(() => { + if (!twofaUrl) { + setTwofaQrUrl(null); + return; + } + let cancelled = false; + void (async () => { + try { + const url = await QRCode.toDataURL(twofaUrl, { margin: 1, width: 192 }); + if (!cancelled) { + setTwofaQrUrl(url); + } + } catch { + if (!cancelled) { + setTwofaQrUrl(null); + } + } + })(); + return () => { + cancelled = true; + }; + }, [twofaUrl]); + useEffect(() => { if (!open) { return; @@ -268,9 +300,62 @@ export function SettingsPanel({ open, onClose }: Props) {

Privacy

- - - +
+

Who can see my profile photo?

+ +
+
+

Who can see my last seen?

+ +
+
+

Who can add me to groups?

+ +
+

Secret

{twofaSecret}

+ {twofaQrUrl ? ( +
+ TOTP QR +
+ ) : null} {twofaUrl ?

{twofaUrl}

: null}
) : null} @@ -362,6 +452,7 @@ export function SettingsPanel({ open, onClose }: Props) { setTwofaCode(""); setTwofaSecret(null); setTwofaUrl(null); + setTwofaQrUrl(null); } catch { setTwofaError("Invalid 2FA code"); } @@ -382,6 +473,8 @@ export function SettingsPanel({ open, onClose }: Props) { onClick={async () => { await revokeAllSessions(); setSessions([]); + logout(); + onClose(); }} type="button" > diff --git a/web/src/index.css b/web/src/index.css index a34aa9a..b1be220 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -6,12 +6,12 @@ :root { --bm-font-size: 16px; - --bm-bg-primary: #101e30; - --bm-bg-secondary: #162233; - --bm-bg-tertiary: #19283a; - --bm-text-color: #e5edf9; - --bm-panel-bg: rgba(19, 31, 47, 0.9); - --bm-panel-border: rgba(146, 174, 208, 0.14); + --bm-bg-primary: #0e1621; + --bm-bg-secondary: #111b27; + --bm-bg-tertiary: #0f1a26; + --bm-text-color: #e6edf5; + --bm-panel-bg: rgba(23, 33, 43, 0.96); + --bm-panel-border: rgba(120, 146, 171, 0.22); } html, @@ -25,8 +25,8 @@ body { font-family: "Manrope", "Segoe UI", sans-serif; font-size: var(--bm-font-size, 16px); background: - radial-gradient(circle at 22% 18%, rgba(36, 68, 117, 0.35), transparent 26%), - radial-gradient(circle at 82% 12%, rgba(24, 95, 102, 0.25), transparent 30%), + radial-gradient(circle at 16% 12%, rgba(45, 85, 132, 0.24), transparent 28%), + radial-gradient(circle at 88% 16%, rgba(36, 112, 122, 0.14), transparent 30%), linear-gradient(180deg, var(--bm-bg-primary) 0%, var(--bm-bg-secondary) 55%, var(--bm-bg-tertiary) 100%); color: var(--bm-text-color); } @@ -43,9 +43,9 @@ body { .tg-chat-wallpaper { background: - radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.09), transparent 30%), - radial-gradient(circle at 86% 74%, rgba(34, 197, 94, 0.07), transparent 33%), - linear-gradient(160deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.015) 100%); + radial-gradient(circle at 14% 18%, rgba(51, 144, 236, 0.1), transparent 32%), + radial-gradient(circle at 86% 74%, rgba(66, 124, 173, 0.09), transparent 35%), + linear-gradient(160deg, rgba(255, 255, 255, 0.018) 0%, rgba(255, 255, 255, 0.01) 100%); } .tg-scrollbar::-webkit-scrollbar { @@ -53,47 +53,132 @@ body { } .tg-scrollbar::-webkit-scrollbar-thumb { - background: rgba(126, 159, 201, 0.35); + background: rgba(113, 145, 177, 0.45); border-radius: 999px; } +html[data-theme="dark"] .bg-slate-900\/95, +html[data-theme="dark"] .bg-slate-900\/90, +html[data-theme="dark"] .bg-slate-900\/80, +html[data-theme="dark"] .bg-slate-900\/70, +html[data-theme="dark"] .bg-slate-900\/65, +html[data-theme="dark"] .bg-slate-900\/60, +html[data-theme="dark"] .bg-slate-900\/50, +html[data-theme="dark"] .bg-slate-900 { + background-color: rgba(23, 33, 43, 0.95) !important; +} + +html[data-theme="dark"] .bg-slate-800\/80, +html[data-theme="dark"] .bg-slate-800\/70, +html[data-theme="dark"] .bg-slate-800\/60, +html[data-theme="dark"] .bg-slate-800\/50, +html[data-theme="dark"] .bg-slate-800 { + background-color: rgba(28, 40, 52, 0.95) !important; +} + +html[data-theme="dark"] .bg-slate-700\/80, +html[data-theme="dark"] .bg-slate-700\/70, +html[data-theme="dark"] .bg-slate-700\/60, +html[data-theme="dark"] .bg-slate-700 { + background-color: rgba(44, 58, 73, 0.95) !important; +} + +html[data-theme="dark"] .border-slate-700\/80, +html[data-theme="dark"] .border-slate-700\/70, +html[data-theme="dark"] .border-slate-700\/60, +html[data-theme="dark"] .border-slate-700\/50, +html[data-theme="dark"] .border-slate-700, +html[data-theme="dark"] .border-slate-800\/60, +html[data-theme="dark"] .border-slate-800, +html[data-theme="dark"] .border-slate-900 { + border-color: rgba(88, 114, 138, 0.42) !important; +} + +html[data-theme="dark"] .text-slate-100 { + color: #f3f7fb !important; +} + +html[data-theme="dark"] .text-slate-200 { + color: #d9e4ef !important; +} + +html[data-theme="dark"] .text-slate-300 { + color: #b9cbdd !important; +} + +html[data-theme="dark"] .text-slate-400 { + color: #92a8bd !important; +} + +html[data-theme="dark"] .hover\:bg-slate-800\/70:hover, +html[data-theme="dark"] .hover\:bg-slate-800\/65:hover, +html[data-theme="dark"] .hover\:bg-slate-800\/60:hover, +html[data-theme="dark"] .hover\:bg-slate-800:hover, +html[data-theme="dark"] .hover\:bg-slate-700\/80:hover, +html[data-theme="dark"] .hover\:bg-slate-700:hover { + background-color: rgba(46, 62, 79, 0.96) !important; +} + +html[data-theme="dark"] .bg-sky-500, +html[data-theme="dark"] .bg-sky-500\/30 { + background-color: rgba(51, 144, 236, 0.92) !important; +} + +html[data-theme="dark"] .hover\:bg-sky-400:hover { + background-color: rgba(88, 167, 244, 0.95) !important; +} + +html[data-theme="dark"] .text-slate-950 { + color: #04101d !important; +} + +html[data-theme="dark"] .from-sky-500\/95 { + --tw-gradient-from: rgba(51, 144, 236, 0.94) var(--tw-gradient-from-position) !important; +} + +html[data-theme="dark"] .to-sky-600\/90 { + --tw-gradient-to: rgba(40, 124, 209, 0.94) var(--tw-gradient-to-position) !important; +} + html[data-theme="light"] { - --bm-bg-primary: #eaf1fb; - --bm-bg-secondary: #f3f7fd; - --bm-bg-tertiary: #fbfdff; - --bm-text-color: #0f172a; - --bm-panel-bg: rgba(255, 255, 255, 0.96); - --bm-panel-border: rgba(15, 23, 42, 0.14); + --bm-bg-primary: #dfe5ec; + --bm-bg-secondary: #e8edf4; + --bm-bg-tertiary: #eff3f8; + --bm-text-color: #1b2733; + --bm-panel-bg: rgba(255, 255, 255, 0.98); + --bm-panel-border: rgba(82, 105, 128, 0.24); } html[data-theme="light"] .tg-chat-wallpaper { background: - radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.09), transparent 32%), - radial-gradient(circle at 86% 74%, rgba(14, 165, 233, 0.06), transparent 35%), - linear-gradient(160deg, rgba(148, 163, 184, 0.08) 0%, rgba(148, 163, 184, 0.03) 100%); + radial-gradient(circle at 14% 18%, rgba(51, 144, 236, 0.08), transparent 32%), + radial-gradient(circle at 86% 74%, rgba(93, 129, 163, 0.07), transparent 35%), + linear-gradient(160deg, rgba(125, 144, 164, 0.09) 0%, rgba(125, 144, 164, 0.02) 100%); } html[data-theme="light"] .bg-slate-900\/95, html[data-theme="light"] .bg-slate-900\/90, html[data-theme="light"] .bg-slate-900\/80, html[data-theme="light"] .bg-slate-900\/70, +html[data-theme="light"] .bg-slate-900\/65, html[data-theme="light"] .bg-slate-900\/60, html[data-theme="light"] .bg-slate-900 { - background-color: rgba(255, 255, 255, 0.97) !important; + background-color: rgba(255, 255, 255, 0.98) !important; } html[data-theme="light"] .bg-slate-800\/80, html[data-theme="light"] .bg-slate-800\/70, html[data-theme="light"] .bg-slate-800\/60, +html[data-theme="light"] .bg-slate-800\/50, html[data-theme="light"] .bg-slate-800 { - background-color: rgba(241, 245, 249, 0.95) !important; + background-color: rgba(242, 246, 251, 0.97) !important; } html[data-theme="light"] .bg-slate-700\/80, html[data-theme="light"] .bg-slate-700\/70, html[data-theme="light"] .bg-slate-700\/60, html[data-theme="light"] .bg-slate-700 { - background-color: rgba(226, 232, 240, 0.95) !important; + background-color: rgba(225, 233, 242, 0.97) !important; } html[data-theme="light"] .border-slate-700\/80, @@ -101,39 +186,39 @@ html[data-theme="light"] .border-slate-700\/70, html[data-theme="light"] .border-slate-700\/60, html[data-theme="light"] .border-slate-700\/50, html[data-theme="light"] .border-slate-700 { - border-color: rgba(71, 85, 105, 0.28) !important; + border-color: rgba(96, 120, 145, 0.3) !important; } html[data-theme="light"] .text-slate-100 { - color: #0f172a !important; + color: #1b2733 !important; } html[data-theme="light"] .text-slate-200 { - color: #1e293b !important; + color: #223446 !important; } html[data-theme="light"] .text-slate-300 { - color: #334155 !important; + color: #385066 !important; } html[data-theme="light"] .text-slate-400 { - color: #64748b !important; + color: #5e748b !important; } html[data-theme="light"] .text-slate-500 { - color: #94a3b8 !important; + color: #7f95ab !important; } html[data-theme="light"] .hover\:bg-slate-800\/70:hover, html[data-theme="light"] .hover\:bg-slate-800\/65:hover, html[data-theme="light"] .hover\:bg-slate-800\/60:hover, html[data-theme="light"] .hover\:bg-slate-800:hover { - background-color: rgba(226, 232, 240, 0.95) !important; + background-color: rgba(230, 237, 245, 0.98) !important; } html[data-theme="light"] .hover\:bg-slate-700\/80:hover, html[data-theme="light"] .hover\:bg-slate-700:hover { - background-color: rgba(203, 213, 225, 0.9) !important; + background-color: rgba(214, 224, 235, 0.98) !important; } html[data-theme="light"] .tg-panel { @@ -163,15 +248,23 @@ html[data-theme="light"] .text-black { } html[data-theme="light"] .bg-sky-500\/30 { - background-color: rgba(14, 165, 233, 0.2) !important; + background-color: rgba(51, 144, 236, 0.22) !important; +} + +html[data-theme="light"] .bg-sky-500 { + background-color: #3390ec !important; +} + +html[data-theme="light"] .hover\:bg-sky-400:hover { + background-color: #59a7f4 !important; } html[data-theme="light"] .text-sky-100 { - color: #0369a1 !important; + color: #1f79d8 !important; } html[data-theme="light"] .text-sky-300 { - color: #075985 !important; + color: #2669ad !important; } html[data-theme="light"] .text-red-400, @@ -190,31 +283,31 @@ html[data-theme="light"] .text-emerald-400 { html[data-theme="light"] .border-slate-800\/60, html[data-theme="light"] .border-slate-800, html[data-theme="light"] .border-slate-900 { - border-color: rgba(71, 85, 105, 0.2) !important; + border-color: rgba(96, 120, 145, 0.24) !important; } html[data-theme="light"] .bg-slate-900\/50 { - background-color: rgba(248, 250, 252, 0.95) !important; + background-color: rgba(247, 250, 253, 0.98) !important; } html[data-theme="light"] .bg-slate-800\/50 { - background-color: rgba(241, 245, 249, 0.88) !important; + background-color: rgba(238, 244, 250, 0.94) !important; } html[data-theme="light"] .text-slate-600 { - color: #475569 !important; + color: #496078 !important; } html[data-theme="light"] .text-slate-700 { - color: #334155 !important; + color: #3e5267 !important; } html[data-theme="light"] .text-slate-800 { - color: #1e293b !important; + color: #2c3e52 !important; } html[data-theme="light"] .text-slate-900 { - color: #0f172a !important; + color: #1d2f42 !important; } html[data-theme="light"] .bg-white { @@ -222,21 +315,29 @@ html[data-theme="light"] .bg-white { } html[data-theme="light"] .bg-slate-100 { - background-color: #f1f5f9 !important; + background-color: #eef3f8 !important; } html[data-theme="light"] .bg-slate-200 { - background-color: #e2e8f0 !important; + background-color: #dde7f1 !important; } html[data-theme="light"] .border-slate-600, html[data-theme="light"] .border-slate-500 { - border-color: rgba(100, 116, 139, 0.35) !important; + border-color: rgba(100, 122, 145, 0.35) !important; } html[data-theme="light"] .ring-slate-700, html[data-theme="light"] .ring-slate-600 { - --tw-ring-color: rgba(100, 116, 139, 0.35) !important; + --tw-ring-color: rgba(74, 126, 184, 0.35) !important; +} + +html[data-theme="light"] .from-sky-500\/95 { + --tw-gradient-from: rgba(51, 144, 236, 0.95) var(--tw-gradient-from-position) !important; +} + +html[data-theme="light"] .to-sky-600\/90 { + --tw-gradient-to: rgba(47, 132, 221, 0.92) var(--tw-gradient-to-position) !important; } html[data-theme="light"] .text-slate-100,