feat(auth,privacy,web): step-by-step login, privacy settings persistence, TOTP QR, and API docs
Some checks failed
CI / test (push) Failing after 22s
Some checks failed
CI / test (push) Failing after 22s
This commit is contained in:
39
alembic/versions/0019_user_privacy_fields.py
Normal file
39
alembic/versions/0019_user_privacy_fields.py
Normal file
@@ -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")
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.auth.schemas import (
|
from app.auth.schemas import (
|
||||||
AuthUserResponse,
|
AuthUserResponse,
|
||||||
|
EmailStatusResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
RefreshTokenRequest,
|
RefreshTokenRequest,
|
||||||
@@ -30,6 +31,7 @@ from app.auth.service import (
|
|||||||
revoke_user_session,
|
revoke_user_session,
|
||||||
refresh_tokens,
|
refresh_tokens,
|
||||||
register_user,
|
register_user,
|
||||||
|
get_email_status,
|
||||||
request_password_reset,
|
request_password_reset,
|
||||||
resend_verification_email,
|
resend_verification_email,
|
||||||
reset_password,
|
reset_password,
|
||||||
@@ -45,6 +47,14 @@ from app.users.models import User
|
|||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
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)
|
@router.post("/register", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def register(
|
async def register(
|
||||||
payload: RegisterRequest,
|
payload: RegisterRequest,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
from app.users.schemas import GroupInvitePrivacyLevel, PrivacyLevel
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
@@ -58,6 +59,10 @@ class AuthUserResponse(BaseModel):
|
|||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
email_verified: bool
|
email_verified: bool
|
||||||
twofa_enabled: 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
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -76,3 +81,10 @@ class TwoFactorSetupRead(BaseModel):
|
|||||||
|
|
||||||
class TwoFactorCodeRequest(BaseModel):
|
class TwoFactorCodeRequest(BaseModel):
|
||||||
code: str = Field(min_length=6, max_length=8)
|
code: str = Field(min_length=6, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailStatusResponse(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
registered: bool
|
||||||
|
email_verified: bool = False
|
||||||
|
twofa_enabled: bool = False
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.auth.token_store import (
|
|||||||
store_refresh_token_jti,
|
store_refresh_token_jti,
|
||||||
)
|
)
|
||||||
from app.auth.schemas import (
|
from app.auth.schemas import (
|
||||||
|
EmailStatusResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
RefreshTokenRequest,
|
RefreshTokenRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
@@ -343,3 +344,15 @@ async def get_current_user_for_ws(token: str, db: AsyncSession) -> User:
|
|||||||
|
|
||||||
def get_email_sender() -> EmailService:
|
def get_email_sender() -> EmailService:
|
||||||
return get_email_service()
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class User(Base):
|
|||||||
bio: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
bio: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=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")
|
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_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false")
|
||||||
twofa_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ async def update_profile(
|
|||||||
bio=payload.bio,
|
bio=payload.bio,
|
||||||
avatar_url=payload.avatar_url,
|
avatar_url=payload.avatar_url,
|
||||||
allow_private_messages=payload.allow_private_messages,
|
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
|
return updated
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
PrivacyLevel = Literal["everyone", "contacts", "nobody"]
|
||||||
|
GroupInvitePrivacyLevel = Literal["everyone", "contacts"]
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
@@ -21,6 +26,9 @@ class UserRead(UserBase):
|
|||||||
bio: str | None = None
|
bio: str | None = None
|
||||||
email_verified: bool
|
email_verified: bool
|
||||||
allow_private_messages: bool
|
allow_private_messages: bool
|
||||||
|
privacy_last_seen: PrivacyLevel = "everyone"
|
||||||
|
privacy_avatar: PrivacyLevel = "everyone"
|
||||||
|
privacy_group_invites: GroupInvitePrivacyLevel = "everyone"
|
||||||
twofa_enabled: bool = False
|
twofa_enabled: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -32,6 +40,9 @@ class UserProfileUpdate(BaseModel):
|
|||||||
bio: str | None = Field(default=None, max_length=500)
|
bio: str | None = Field(default=None, max_length=500)
|
||||||
avatar_url: str | None = Field(default=None, max_length=512)
|
avatar_url: str | None = Field(default=None, max_length=512)
|
||||||
allow_private_messages: bool | None = None
|
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):
|
class UserSearchRead(BaseModel):
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ async def update_user_profile(
|
|||||||
bio: str | None = None,
|
bio: str | None = None,
|
||||||
avatar_url: str | None = None,
|
avatar_url: str | None = None,
|
||||||
allow_private_messages: bool | 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:
|
) -> User:
|
||||||
if name is not None:
|
if name is not None:
|
||||||
user.name = name
|
user.name = name
|
||||||
@@ -52,6 +55,12 @@ async def update_user_profile(
|
|||||||
user.avatar_url = avatar_url
|
user.avatar_url = avatar_url
|
||||||
if allow_private_messages is not None:
|
if allow_private_messages is not None:
|
||||||
user.allow_private_messages = allow_private_messages
|
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.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|||||||
23
docs/README.md
Normal file
23
docs/README.md
Normal file
@@ -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 <access_token>`
|
||||||
|
|
||||||
938
docs/api-reference.md
Normal file
938
docs/api-reference.md
Normal file
@@ -0,0 +1,938 @@
|
|||||||
|
# REST API Reference
|
||||||
|
|
||||||
|
## 1. Conventions
|
||||||
|
|
||||||
|
Base path: `/api/v1`
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
|
||||||
|
- Use JWT access token in header: `Authorization: Bearer <token>`
|
||||||
|
- 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=<text>&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=<text>&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=<text>&chat_id=<id>&limit=50`
|
||||||
|
|
||||||
|
Auth required.
|
||||||
|
Response: `200` + `MessageRead[]`
|
||||||
|
|
||||||
|
### GET `/api/v1/messages/{chat_id}?limit=50&before_id=<message_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=<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=<text>&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.
|
||||||
|
|
||||||
240
docs/realtime.md
Normal file
240
docs/realtime.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Realtime WebSocket API
|
||||||
|
|
||||||
|
WebSocket endpoint:
|
||||||
|
|
||||||
|
- `GET /api/v1/realtime/ws?token=<access_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.
|
||||||
|
|
||||||
334
web/package-lock.json
generated
334
web/package-lock.json
generated
@@ -9,11 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "1.11.0",
|
"axios": "1.11.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"zustand": "5.0.8"
|
"zustand": "5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "18.3.24",
|
"@types/react": "18.3.24",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
"@vitejs/plugin-react": "5.0.2",
|
"@vitejs/plugin-react": "5.0.2",
|
||||||
@@ -1258,6 +1260,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -1265,6 +1277,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.24",
|
"version": "18.3.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
|
"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"
|
"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": {
|
"node_modules/any-promise": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
@@ -1476,6 +1522,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@@ -1545,6 +1600,35 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"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": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -1628,6 +1721,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
@@ -1656,6 +1755,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -1806,6 +1911,19 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.15.11",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
@@ -1890,6 +2008,15 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -2030,6 +2157,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -2115,6 +2251,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -2276,6 +2424,51 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
@@ -2323,6 +2516,15 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -2485,6 +2687,23 @@
|
|||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -2564,6 +2783,21 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -2684,6 +2918,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2694,6 +2934,32 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||||
@@ -2873,6 +3139,13 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"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"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@@ -3040,6 +3339,41 @@
|
|||||||
"url": "https://github.com/sponsors/eemeli"
|
"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": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||||
|
|||||||
@@ -10,11 +10,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "1.11.0",
|
"axios": "1.11.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"zustand": "5.0.8"
|
"zustand": "5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "18.3.24",
|
"@types/react": "18.3.24",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
"@vitejs/plugin-react": "5.0.2",
|
"@vitejs/plugin-react": "5.0.2",
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ export async function revokeAllSessions(): Promise<void> {
|
|||||||
await http.delete("/auth/sessions");
|
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<EmailStatusResponse> {
|
||||||
|
const { data } = await http.get<EmailStatusResponse>("/auth/check-email", { params: { email } });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TwoFactorSetupResponse {
|
export interface TwoFactorSetupResponse {
|
||||||
secret: string;
|
secret: string;
|
||||||
otpauth_url: string;
|
otpauth_url: string;
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ interface UserProfileUpdatePayload {
|
|||||||
bio?: string | null;
|
bio?: string | null;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
allow_private_messages?: boolean;
|
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<AuthUser> {
|
export async function updateMyProfile(payload: UserProfileUpdatePayload): Promise<AuthUser> {
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export interface AuthUser {
|
|||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
twofa_enabled?: boolean;
|
twofa_enabled?: boolean;
|
||||||
allow_private_messages: boolean;
|
allow_private_messages: boolean;
|
||||||
|
privacy_last_seen?: "everyone" | "contacts" | "nobody";
|
||||||
|
privacy_avatar?: "everyone" | "contacts" | "nobody";
|
||||||
|
privacy_group_invites?: "everyone" | "contacts";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
|
import axios from "axios";
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { registerRequest } from "../api/auth";
|
import { checkEmailStatus, registerRequest } from "../api/auth";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
|
|
||||||
type Mode = "login" | "register";
|
type Step = "email" | "password" | "register" | "otp";
|
||||||
|
|
||||||
export function AuthPanel() {
|
export function AuthPanel() {
|
||||||
const login = useAuthStore((s) => s.login);
|
const login = useAuthStore((s) => s.login);
|
||||||
const loading = useAuthStore((s) => s.loading);
|
const loading = useAuthStore((s) => s.loading);
|
||||||
const [mode, setMode] = useState<Mode>("login");
|
const [step, setStep] = useState<Step>("email");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [otpCode, setOtpCode] = useState("");
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [checkingEmail, setCheckingEmail] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -21,47 +23,135 @@ export function AuthPanel() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
try {
|
try {
|
||||||
if (mode === "register") {
|
if (step === "email") {
|
||||||
await registerRequest(email, name, username, password);
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
setSuccess("Registered. Check email verification, then login.");
|
if (!normalizedEmail) {
|
||||||
setMode("login");
|
setError("Enter email.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCheckingEmail(true);
|
||||||
|
try {
|
||||||
|
const status = await checkEmailStatus(normalizedEmail);
|
||||||
|
setEmail(normalizedEmail);
|
||||||
|
setStep(status.registered ? "password" : "register");
|
||||||
|
} finally {
|
||||||
|
setCheckingEmail(false);
|
||||||
|
}
|
||||||
return;
|
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);
|
await login(email, password, otpCode.trim() || undefined);
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError("Auth request failed.");
|
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 (
|
return (
|
||||||
<div className="mx-auto mt-16 w-full max-w-md rounded-xl bg-panel p-6 shadow-xl">
|
<div className="mx-auto mt-16 w-full max-w-md rounded-2xl border border-slate-700/70 bg-panel p-6 shadow-xl">
|
||||||
<div className="mb-4 flex gap-2">
|
<p className="mb-1 text-xl font-semibold">
|
||||||
<button className={`rounded px-3 py-2 ${mode === "login" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("login")}>
|
{step === "email" ? "Sign in to BenyaMessenger" : step === "register" ? "Create account" : "Enter credentials"}
|
||||||
Login
|
</p>
|
||||||
</button>
|
<p className="mb-4 text-sm text-slate-400">
|
||||||
<button className={`rounded px-3 py-2 ${mode === "register" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("register")}>
|
{step === "email"
|
||||||
Register
|
? "Enter your email to continue"
|
||||||
</button>
|
: step === "register"
|
||||||
</div>
|
? "This email is not registered yet. Complete registration."
|
||||||
|
: step === "password"
|
||||||
|
? "Enter your password"
|
||||||
|
: "Two-factor authentication is enabled"}
|
||||||
|
</p>
|
||||||
|
|
||||||
<form className="space-y-3" onSubmit={onSubmit}>
|
<form className="space-y-3" onSubmit={onSubmit}>
|
||||||
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
<div className="space-y-2">
|
||||||
{mode === "register" && (
|
|
||||||
<>
|
|
||||||
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
|
||||||
{mode === "login" ? (
|
|
||||||
<input
|
<input
|
||||||
className="w-full rounded bg-slate-800 px-3 py-2"
|
className="w-full rounded bg-slate-800 px-3 py-2"
|
||||||
placeholder="2FA code (if enabled)"
|
disabled={step !== "email"}
|
||||||
|
placeholder="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
{step !== "email" ? (
|
||||||
|
<button className="text-xs text-sky-300 hover:text-sky-200" onClick={resetToEmail} type="button">
|
||||||
|
Change email
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === "register" ? (
|
||||||
|
<>
|
||||||
|
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<input
|
||||||
|
className="w-full rounded bg-slate-800 px-3 py-2"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value.replace("@", ""))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === "password" || step === "register" || step === "otp" ? (
|
||||||
|
<input
|
||||||
|
className="w-full rounded bg-slate-800 px-3 py-2"
|
||||||
|
placeholder="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === "otp" ? (
|
||||||
|
<input
|
||||||
|
className="w-full rounded bg-slate-800 px-3 py-2"
|
||||||
|
placeholder="2FA code"
|
||||||
value={otpCode}
|
value={otpCode}
|
||||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 8))}
|
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 8))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<button className="w-full rounded bg-accent px-3 py-2 font-semibold text-black disabled:opacity-50" disabled={loading} type="submit">
|
|
||||||
{mode === "login" ? "Sign in" : "Create account"}
|
<button className="w-full rounded bg-accent px-3 py-2 font-semibold text-black disabled:opacity-50" disabled={isBusy} type="submit">
|
||||||
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : null}
|
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : null}
|
||||||
@@ -69,3 +159,19 @@ export function AuthPanel() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import QRCode from "qrcode";
|
||||||
import { listBlockedUsers, updateMyProfile } from "../api/users";
|
import { listBlockedUsers, updateMyProfile } from "../api/users";
|
||||||
import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth";
|
import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth";
|
||||||
import type { AuthSession, AuthUser } from "../chat/types";
|
import type { AuthSession, AuthUser } from "../chat/types";
|
||||||
@@ -15,6 +16,7 @@ interface Props {
|
|||||||
|
|
||||||
export function SettingsPanel({ open, onClose }: Props) {
|
export function SettingsPanel({ open, onClose }: Props) {
|
||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
const [page, setPage] = useState<SettingsPage>("main");
|
const [page, setPage] = useState<SettingsPage>("main");
|
||||||
const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences());
|
const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences());
|
||||||
const [blockedCount, setBlockedCount] = useState(0);
|
const [blockedCount, setBlockedCount] = useState(0);
|
||||||
@@ -25,7 +27,11 @@ export function SettingsPanel({ open, onClose }: Props) {
|
|||||||
const [twofaCode, setTwofaCode] = useState("");
|
const [twofaCode, setTwofaCode] = useState("");
|
||||||
const [twofaSecret, setTwofaSecret] = useState<string | null>(null);
|
const [twofaSecret, setTwofaSecret] = useState<string | null>(null);
|
||||||
const [twofaUrl, setTwofaUrl] = useState<string | null>(null);
|
const [twofaUrl, setTwofaUrl] = useState<string | null>(null);
|
||||||
|
const [twofaQrUrl, setTwofaQrUrl] = useState<string | null>(null);
|
||||||
const [twofaError, setTwofaError] = useState<string | null>(null);
|
const [twofaError, setTwofaError] = useState<string | null>(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({
|
const [profileDraft, setProfileDraft] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: "",
|
||||||
@@ -38,6 +44,9 @@ export function SettingsPanel({ open, onClose }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAllowPrivateMessages(me.allow_private_messages);
|
setAllowPrivateMessages(me.allow_private_messages);
|
||||||
|
setPrivacyLastSeen(me.privacy_last_seen || "everyone");
|
||||||
|
setPrivacyAvatar(me.privacy_avatar || "everyone");
|
||||||
|
setPrivacyGroupInvites(me.privacy_group_invites || "everyone");
|
||||||
setProfileDraft({
|
setProfileDraft({
|
||||||
name: me.name || "",
|
name: me.name || "",
|
||||||
username: me.username || "",
|
username: me.username || "",
|
||||||
@@ -46,6 +55,29 @@ export function SettingsPanel({ open, onClose }: Props) {
|
|||||||
});
|
});
|
||||||
}, [me]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
@@ -268,9 +300,62 @@ export function SettingsPanel({ open, onClose }: Props) {
|
|||||||
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
||||||
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Privacy</p>
|
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Privacy</p>
|
||||||
<SettingsTextRow label="Blocked Users" value={String(blockedCount)} />
|
<SettingsTextRow label="Blocked Users" value={String(blockedCount)} />
|
||||||
<SettingsTextRow label="Who can see my profile photo?" value="Everybody" />
|
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
|
||||||
<SettingsTextRow label="Who can see my last seen?" value="Everybody" />
|
<p className="mb-1 text-xs text-slate-300">Who can see my profile photo?</p>
|
||||||
<SettingsTextRow label="Who can add me to groups?" value="Everybody" />
|
<select
|
||||||
|
className="w-full rounded bg-slate-800 px-2 py-1 text-xs"
|
||||||
|
value={privacyAvatar}
|
||||||
|
onChange={(e) => setPrivacyAvatar(e.target.value as "everyone" | "contacts" | "nobody")}
|
||||||
|
>
|
||||||
|
<option value="everyone">Everybody</option>
|
||||||
|
<option value="contacts">My contacts</option>
|
||||||
|
<option value="nobody">Nobody</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
|
||||||
|
<p className="mb-1 text-xs text-slate-300">Who can see my last seen?</p>
|
||||||
|
<select
|
||||||
|
className="w-full rounded bg-slate-800 px-2 py-1 text-xs"
|
||||||
|
value={privacyLastSeen}
|
||||||
|
onChange={(e) => setPrivacyLastSeen(e.target.value as "everyone" | "contacts" | "nobody")}
|
||||||
|
>
|
||||||
|
<option value="everyone">Everybody</option>
|
||||||
|
<option value="contacts">My contacts</option>
|
||||||
|
<option value="nobody">Nobody</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
|
||||||
|
<p className="mb-1 text-xs text-slate-300">Who can add me to groups?</p>
|
||||||
|
<select
|
||||||
|
className="w-full rounded bg-slate-800 px-2 py-1 text-xs"
|
||||||
|
value={privacyGroupInvites}
|
||||||
|
onChange={(e) => setPrivacyGroupInvites(e.target.value as "everyone" | "contacts")}
|
||||||
|
>
|
||||||
|
<option value="everyone">Everybody</option>
|
||||||
|
<option value="contacts">My contacts</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
|
||||||
|
onClick={async () => {
|
||||||
|
setSavingPrivacy(true);
|
||||||
|
try {
|
||||||
|
const updated = await updateMyProfile({
|
||||||
|
allow_private_messages: allowPrivateMessages,
|
||||||
|
privacy_last_seen: privacyLastSeen,
|
||||||
|
privacy_avatar: privacyAvatar,
|
||||||
|
privacy_group_invites: privacyGroupInvites,
|
||||||
|
});
|
||||||
|
useAuthStore.setState({ me: updated as AuthUser });
|
||||||
|
} finally {
|
||||||
|
setSavingPrivacy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={savingPrivacy}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{savingPrivacy ? "Saving..." : "Save privacy settings"}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
|
||||||
<CheckboxOption
|
<CheckboxOption
|
||||||
@@ -316,6 +401,11 @@ export function SettingsPanel({ open, onClose }: Props) {
|
|||||||
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
|
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
|
||||||
<p className="text-[11px] text-slate-400">Secret</p>
|
<p className="text-[11px] text-slate-400">Secret</p>
|
||||||
<p className="break-all text-xs text-slate-200">{twofaSecret}</p>
|
<p className="break-all text-xs text-slate-200">{twofaSecret}</p>
|
||||||
|
{twofaQrUrl ? (
|
||||||
|
<div className="mt-2 rounded bg-white p-2">
|
||||||
|
<img alt="TOTP QR" className="mx-auto h-44 w-44" src={twofaQrUrl} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{twofaUrl ? <p className="mt-1 break-all text-[11px] text-slate-500">{twofaUrl}</p> : null}
|
{twofaUrl ? <p className="mt-1 break-all text-[11px] text-slate-500">{twofaUrl}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -362,6 +452,7 @@ export function SettingsPanel({ open, onClose }: Props) {
|
|||||||
setTwofaCode("");
|
setTwofaCode("");
|
||||||
setTwofaSecret(null);
|
setTwofaSecret(null);
|
||||||
setTwofaUrl(null);
|
setTwofaUrl(null);
|
||||||
|
setTwofaQrUrl(null);
|
||||||
} catch {
|
} catch {
|
||||||
setTwofaError("Invalid 2FA code");
|
setTwofaError("Invalid 2FA code");
|
||||||
}
|
}
|
||||||
@@ -382,6 +473,8 @@ export function SettingsPanel({ open, onClose }: Props) {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await revokeAllSessions();
|
await revokeAllSessions();
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
|
logout();
|
||||||
|
onClose();
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bm-font-size: 16px;
|
--bm-font-size: 16px;
|
||||||
--bm-bg-primary: #101e30;
|
--bm-bg-primary: #0e1621;
|
||||||
--bm-bg-secondary: #162233;
|
--bm-bg-secondary: #111b27;
|
||||||
--bm-bg-tertiary: #19283a;
|
--bm-bg-tertiary: #0f1a26;
|
||||||
--bm-text-color: #e5edf9;
|
--bm-text-color: #e6edf5;
|
||||||
--bm-panel-bg: rgba(19, 31, 47, 0.9);
|
--bm-panel-bg: rgba(23, 33, 43, 0.96);
|
||||||
--bm-panel-border: rgba(146, 174, 208, 0.14);
|
--bm-panel-border: rgba(120, 146, 171, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -25,8 +25,8 @@ body {
|
|||||||
font-family: "Manrope", "Segoe UI", sans-serif;
|
font-family: "Manrope", "Segoe UI", sans-serif;
|
||||||
font-size: var(--bm-font-size, 16px);
|
font-size: var(--bm-font-size, 16px);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 22% 18%, rgba(36, 68, 117, 0.35), transparent 26%),
|
radial-gradient(circle at 16% 12%, rgba(45, 85, 132, 0.24), transparent 28%),
|
||||||
radial-gradient(circle at 82% 12%, rgba(24, 95, 102, 0.25), transparent 30%),
|
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%);
|
linear-gradient(180deg, var(--bm-bg-primary) 0%, var(--bm-bg-secondary) 55%, var(--bm-bg-tertiary) 100%);
|
||||||
color: var(--bm-text-color);
|
color: var(--bm-text-color);
|
||||||
}
|
}
|
||||||
@@ -43,9 +43,9 @@ body {
|
|||||||
|
|
||||||
.tg-chat-wallpaper {
|
.tg-chat-wallpaper {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.09), transparent 30%),
|
radial-gradient(circle at 14% 18%, rgba(51, 144, 236, 0.1), transparent 32%),
|
||||||
radial-gradient(circle at 86% 74%, rgba(34, 197, 94, 0.07), transparent 33%),
|
radial-gradient(circle at 86% 74%, rgba(66, 124, 173, 0.09), transparent 35%),
|
||||||
linear-gradient(160deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.015) 100%);
|
linear-gradient(160deg, rgba(255, 255, 255, 0.018) 0%, rgba(255, 255, 255, 0.01) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-scrollbar::-webkit-scrollbar {
|
.tg-scrollbar::-webkit-scrollbar {
|
||||||
@@ -53,47 +53,132 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tg-scrollbar::-webkit-scrollbar-thumb {
|
.tg-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: rgba(126, 159, 201, 0.35);
|
background: rgba(113, 145, 177, 0.45);
|
||||||
border-radius: 999px;
|
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"] {
|
html[data-theme="light"] {
|
||||||
--bm-bg-primary: #eaf1fb;
|
--bm-bg-primary: #dfe5ec;
|
||||||
--bm-bg-secondary: #f3f7fd;
|
--bm-bg-secondary: #e8edf4;
|
||||||
--bm-bg-tertiary: #fbfdff;
|
--bm-bg-tertiary: #eff3f8;
|
||||||
--bm-text-color: #0f172a;
|
--bm-text-color: #1b2733;
|
||||||
--bm-panel-bg: rgba(255, 255, 255, 0.96);
|
--bm-panel-bg: rgba(255, 255, 255, 0.98);
|
||||||
--bm-panel-border: rgba(15, 23, 42, 0.14);
|
--bm-panel-border: rgba(82, 105, 128, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .tg-chat-wallpaper {
|
html[data-theme="light"] .tg-chat-wallpaper {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 14% 18%, rgba(59, 130, 246, 0.09), transparent 32%),
|
radial-gradient(circle at 14% 18%, rgba(51, 144, 236, 0.08), transparent 32%),
|
||||||
radial-gradient(circle at 86% 74%, rgba(14, 165, 233, 0.06), transparent 35%),
|
radial-gradient(circle at 86% 74%, rgba(93, 129, 163, 0.07), transparent 35%),
|
||||||
linear-gradient(160deg, rgba(148, 163, 184, 0.08) 0%, rgba(148, 163, 184, 0.03) 100%);
|
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\/95,
|
||||||
html[data-theme="light"] .bg-slate-900\/90,
|
html[data-theme="light"] .bg-slate-900\/90,
|
||||||
html[data-theme="light"] .bg-slate-900\/80,
|
html[data-theme="light"] .bg-slate-900\/80,
|
||||||
html[data-theme="light"] .bg-slate-900\/70,
|
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\/60,
|
||||||
html[data-theme="light"] .bg-slate-900 {
|
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\/80,
|
||||||
html[data-theme="light"] .bg-slate-800\/70,
|
html[data-theme="light"] .bg-slate-800\/70,
|
||||||
html[data-theme="light"] .bg-slate-800\/60,
|
html[data-theme="light"] .bg-slate-800\/60,
|
||||||
|
html[data-theme="light"] .bg-slate-800\/50,
|
||||||
html[data-theme="light"] .bg-slate-800 {
|
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\/80,
|
||||||
html[data-theme="light"] .bg-slate-700\/70,
|
html[data-theme="light"] .bg-slate-700\/70,
|
||||||
html[data-theme="light"] .bg-slate-700\/60,
|
html[data-theme="light"] .bg-slate-700\/60,
|
||||||
html[data-theme="light"] .bg-slate-700 {
|
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,
|
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\/60,
|
||||||
html[data-theme="light"] .border-slate-700\/50,
|
html[data-theme="light"] .border-slate-700\/50,
|
||||||
html[data-theme="light"] .border-slate-700 {
|
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 {
|
html[data-theme="light"] .text-slate-100 {
|
||||||
color: #0f172a !important;
|
color: #1b2733 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .text-slate-200 {
|
html[data-theme="light"] .text-slate-200 {
|
||||||
color: #1e293b !important;
|
color: #223446 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .text-slate-300 {
|
html[data-theme="light"] .text-slate-300 {
|
||||||
color: #334155 !important;
|
color: #385066 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .text-slate-400 {
|
html[data-theme="light"] .text-slate-400 {
|
||||||
color: #64748b !important;
|
color: #5e748b !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .text-slate-500 {
|
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\/70:hover,
|
||||||
html[data-theme="light"] .hover\:bg-slate-800\/65: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\/60:hover,
|
||||||
html[data-theme="light"] .hover\:bg-slate-800: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\/80:hover,
|
||||||
html[data-theme="light"] .hover\:bg-slate-700: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 {
|
html[data-theme="light"] .tg-panel {
|
||||||
@@ -163,15 +248,23 @@ html[data-theme="light"] .text-black {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .bg-sky-500\/30 {
|
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 {
|
html[data-theme="light"] .text-sky-100 {
|
||||||
color: #0369a1 !important;
|
color: #1f79d8 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .text-sky-300 {
|
html[data-theme="light"] .text-sky-300 {
|
||||||
color: #075985 !important;
|
color: #2669ad !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .text-red-400,
|
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\/60,
|
||||||
html[data-theme="light"] .border-slate-800,
|
html[data-theme="light"] .border-slate-800,
|
||||||
html[data-theme="light"] .border-slate-900 {
|
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 {
|
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 {
|
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 {
|
html[data-theme="light"] .text-slate-600 {
|
||||||
color: #475569 !important;
|
color: #496078 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .text-slate-700 {
|
html[data-theme="light"] .text-slate-700 {
|
||||||
color: #334155 !important;
|
color: #3e5267 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .text-slate-800 {
|
html[data-theme="light"] .text-slate-800 {
|
||||||
color: #1e293b !important;
|
color: #2c3e52 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .text-slate-900 {
|
html[data-theme="light"] .text-slate-900 {
|
||||||
color: #0f172a !important;
|
color: #1d2f42 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .bg-white {
|
html[data-theme="light"] .bg-white {
|
||||||
@@ -222,21 +315,29 @@ html[data-theme="light"] .bg-white {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .bg-slate-100 {
|
html[data-theme="light"] .bg-slate-100 {
|
||||||
background-color: #f1f5f9 !important;
|
background-color: #eef3f8 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .bg-slate-200 {
|
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-600,
|
||||||
html[data-theme="light"] .border-slate-500 {
|
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-700,
|
||||||
html[data-theme="light"] .ring-slate-600 {
|
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,
|
html[data-theme="light"] .text-slate-100,
|
||||||
|
|||||||
Reference in New Issue
Block a user