privacy/security: add PM privacy levels and improve session visibility
All checks were successful
CI / test (push) Successful in 24s

This commit is contained in:
2026-03-08 14:26:19 +03:00
parent 528778238b
commit 76cc5e0f12
17 changed files with 229 additions and 24 deletions

View File

@@ -0,0 +1,38 @@
"""add privacy private messages level
Revision ID: 0023_privacy_pm_level
Revises: 0022_user_access_revoked_before
Create Date: 2026-03-09 00:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0023_privacy_pm_level"
down_revision: Union[str, Sequence[str], None] = "0022_user_access_revoked_before"
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_private_messages", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")),
)
op.execute(
sa.text(
"UPDATE users "
"SET privacy_private_messages = CASE "
"WHEN allow_private_messages IS TRUE THEN 'everyone' "
"ELSE 'nobody' "
"END"
)
)
def downgrade() -> None:
op.drop_column("users", "privacy_private_messages")

View File

@@ -23,6 +23,7 @@ from app.auth.service import (
disable_twofa, disable_twofa,
enable_twofa, enable_twofa,
get_current_user, get_current_user,
get_access_session_info,
get_email_sender, get_email_sender,
get_request_metadata, get_request_metadata,
login_user, login_user,
@@ -37,6 +38,7 @@ from app.auth.service import (
reset_password, reset_password,
setup_twofa, setup_twofa,
verify_email, verify_email,
oauth2_scheme,
) )
from app.database.session import get_db from app.database.session import get_db
from app.email.service import EmailService from app.email.service import EmailService
@@ -147,7 +149,10 @@ async def me(current_user: User = Depends(get_current_user)) -> AuthUserResponse
@router.get("/sessions", response_model=list[SessionRead]) @router.get("/sessions", response_model=list[SessionRead])
async def list_sessions(current_user: User = Depends(get_current_user)) -> list[SessionRead]: async def list_sessions(
current_user: User = Depends(get_current_user),
access_token: str = Depends(oauth2_scheme),
) -> list[SessionRead]:
sessions = await list_user_sessions(current_user.id) sessions = await list_user_sessions(current_user.id)
out: list[SessionRead] = [] out: list[SessionRead] = []
for item in sessions: for item in sessions:
@@ -157,8 +162,23 @@ async def list_sessions(current_user: User = Depends(get_current_user)) -> list[
created_at=datetime.fromtimestamp(item.created_at, tz=timezone.utc), created_at=datetime.fromtimestamp(item.created_at, tz=timezone.utc),
ip_address=item.ip_address, ip_address=item.ip_address,
user_agent=item.user_agent, user_agent=item.user_agent,
current=False,
token_type="refresh",
) )
) )
access_session = get_access_session_info(access_token)
if access_session and all(item.jti != access_session[0] for item in out):
out.insert(
0,
SessionRead(
jti=access_session[0],
created_at=access_session[1],
ip_address=None,
user_agent="Current access token",
current=True,
token_type="access",
),
)
return out return out

View File

@@ -1,7 +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 from app.users.schemas import GroupInvitePrivacyLevel, PrivacyLevel, PrivateMessagesPrivacyLevel
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
@@ -60,6 +60,7 @@ class AuthUserResponse(BaseModel):
email_verified: bool email_verified: bool
twofa_enabled: bool twofa_enabled: bool
allow_private_messages: bool = True allow_private_messages: bool = True
privacy_private_messages: PrivateMessagesPrivacyLevel = "everyone"
privacy_last_seen: PrivacyLevel = "everyone" privacy_last_seen: PrivacyLevel = "everyone"
privacy_avatar: PrivacyLevel = "everyone" privacy_avatar: PrivacyLevel = "everyone"
privacy_group_invites: GroupInvitePrivacyLevel = "everyone" privacy_group_invites: GroupInvitePrivacyLevel = "everyone"
@@ -72,6 +73,8 @@ class SessionRead(BaseModel):
created_at: datetime created_at: datetime
ip_address: str | None = None ip_address: str | None = None
user_agent: str | None = None user_agent: str | None = None
current: bool = False
token_type: str = "refresh"
class TwoFactorSetupRead(BaseModel): class TwoFactorSetupRead(BaseModel):

View File

@@ -244,6 +244,20 @@ def get_request_metadata(request: Request) -> tuple[str | None, str | None]:
return ip_address, user_agent return ip_address, user_agent
def get_access_session_info(token: str) -> tuple[str, datetime] | None:
try:
payload = decode_token(token)
except ValueError:
return None
if payload.get("type") != "access":
return None
jti = payload.get("jti")
if not isinstance(jti, str) or not jti:
return None
issued_at = _token_issued_at(payload) or datetime.now(timezone.utc)
return jti, issued_at
async def setup_twofa(db: AsyncSession, user: User) -> tuple[str, str]: async def setup_twofa(db: AsyncSession, user: User) -> tuple[str, str]:
if user.twofa_enabled and user.twofa_secret: if user.twofa_enabled and user.twofa_secret:
secret = user.twofa_secret secret = user.twofa_secret

View File

@@ -29,6 +29,7 @@ from app.messages.repository import (
) )
from app.realtime.presence import get_users_online_map from app.realtime.presence import get_users_online_map
from app.users.repository import get_user_by_id, has_block_relation_between_users, is_user_in_contacts from app.users.repository import get_user_by_id, has_block_relation_between_users, is_user_in_contacts
from app.users.service import can_user_receive_private_messages
async def _can_view_last_seen(*, db: AsyncSession, target_user, viewer_user_id: int) -> bool: async def _can_view_last_seen(*, db: AsyncSession, target_user, viewer_user_id: int) -> bool:
@@ -177,7 +178,7 @@ async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: Ch
) )
if payload.type == ChatType.PRIVATE: if payload.type == ChatType.PRIVATE:
target_user = await get_user_by_id(db, member_ids[0]) target_user = await get_user_by_id(db, member_ids[0])
if target_user and not target_user.allow_private_messages: if target_user and not await can_user_receive_private_messages(db, target_user=target_user, actor_user_id=creator_id):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="User does not accept private messages", detail="User does not accept private messages",

View File

@@ -23,7 +23,7 @@ from app.messages.schemas import (
) )
from app.notifications.service import dispatch_message_notifications from app.notifications.service import dispatch_message_notifications
from app.users.repository import has_block_relation_between_users from app.users.repository import has_block_relation_between_users
from app.users.service import get_user_by_id from app.users.service import can_user_receive_private_messages, get_user_by_id
async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message: async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message:
@@ -42,7 +42,7 @@ async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: Mess
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send message due to block settings") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send message due to block settings")
if counterpart_id: if counterpart_id:
counterpart = await get_user_by_id(db, counterpart_id) counterpart = await get_user_by_id(db, counterpart_id)
if counterpart and not counterpart.allow_private_messages: if counterpart and not await can_user_receive_private_messages(db, target_user=counterpart, actor_user_id=sender_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User does not accept private messages") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User does not accept private messages")
if payload.reply_to_message_id is not None: if payload.reply_to_message_id is not None:
reply_to = await repository.get_message_by_id(db, payload.reply_to_message_id) reply_to = await repository.get_message_by_id(db, payload.reply_to_message_id)

View File

@@ -24,6 +24,7 @@ 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_private_messages: Mapped[str] = mapped_column(String(16), nullable=False, default="everyone", server_default="everyone")
privacy_last_seen: Mapped[str] = mapped_column(String(16), nullable=False, default="everyone", server_default="everyone") 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_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") privacy_group_invites: Mapped[str] = mapped_column(String(16), nullable=False, default="everyone", server_default="everyone")

View File

@@ -67,6 +67,7 @@ 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_private_messages=payload.privacy_private_messages,
privacy_last_seen=payload.privacy_last_seen, privacy_last_seen=payload.privacy_last_seen,
privacy_avatar=payload.privacy_avatar, privacy_avatar=payload.privacy_avatar,
privacy_group_invites=payload.privacy_group_invites, privacy_group_invites=payload.privacy_group_invites,

View File

@@ -6,6 +6,7 @@ from typing import Literal
PrivacyLevel = Literal["everyone", "contacts", "nobody"] PrivacyLevel = Literal["everyone", "contacts", "nobody"]
GroupInvitePrivacyLevel = Literal["everyone", "contacts"] GroupInvitePrivacyLevel = Literal["everyone", "contacts"]
PrivateMessagesPrivacyLevel = Literal["everyone", "contacts", "nobody"]
class UserBase(BaseModel): class UserBase(BaseModel):
@@ -26,6 +27,7 @@ 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_private_messages: PrivateMessagesPrivacyLevel = "everyone"
privacy_last_seen: PrivacyLevel = "everyone" privacy_last_seen: PrivacyLevel = "everyone"
privacy_avatar: PrivacyLevel = "everyone" privacy_avatar: PrivacyLevel = "everyone"
privacy_group_invites: GroupInvitePrivacyLevel = "everyone" privacy_group_invites: GroupInvitePrivacyLevel = "everyone"
@@ -40,6 +42,7 @@ 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_private_messages: PrivateMessagesPrivacyLevel | None = None
privacy_last_seen: PrivacyLevel | None = None privacy_last_seen: PrivacyLevel | None = None
privacy_avatar: PrivacyLevel | None = None privacy_avatar: PrivacyLevel | None = None
privacy_group_invites: GroupInvitePrivacyLevel | None = None privacy_group_invites: GroupInvitePrivacyLevel | None = None

View File

@@ -42,6 +42,7 @@ 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_private_messages: str | None = None,
privacy_last_seen: str | None = None, privacy_last_seen: str | None = None,
privacy_avatar: str | None = None, privacy_avatar: str | None = None,
privacy_group_invites: str | None = None, privacy_group_invites: str | None = None,
@@ -56,6 +57,11 @@ 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_private_messages is None:
user.privacy_private_messages = "everyone" if allow_private_messages else "nobody"
if privacy_private_messages is not None:
user.privacy_private_messages = privacy_private_messages
user.allow_private_messages = privacy_private_messages != "nobody"
if privacy_last_seen is not None: if privacy_last_seen is not None:
user.privacy_last_seen = privacy_last_seen user.privacy_last_seen = privacy_last_seen
if privacy_avatar is not None: if privacy_avatar is not None:
@@ -127,12 +133,25 @@ async def can_invite_user_to_groups(db: AsyncSession, *, target_user: User, acto
return await repository.is_user_in_contacts(db, owner_user_id=target_user.id, candidate_user_id=actor_user_id) return await repository.is_user_in_contacts(db, owner_user_id=target_user.id, candidate_user_id=actor_user_id)
async def can_user_receive_private_messages(db: AsyncSession, *, target_user: User, actor_user_id: int) -> bool:
if target_user.id == actor_user_id:
return True
policy = target_user.privacy_private_messages or ("everyone" if target_user.allow_private_messages else "nobody")
if policy == "everyone":
return True
if policy == "nobody":
return False
return await repository.is_user_in_contacts(db, owner_user_id=target_user.id, candidate_user_id=actor_user_id)
async def serialize_user_for_viewer(db: AsyncSession, *, target_user: User, viewer_user_id: int) -> UserRead: async def serialize_user_for_viewer(db: AsyncSession, *, target_user: User, viewer_user_id: int) -> UserRead:
payload = UserRead.model_validate(target_user).model_dump() payload = UserRead.model_validate(target_user).model_dump()
payload["allow_private_messages"] = bool(target_user.privacy_private_messages != "nobody")
if not await can_view_user_avatar(db, target_user=target_user, viewer_user_id=viewer_user_id): if not await can_view_user_avatar(db, target_user=target_user, viewer_user_id=viewer_user_id):
payload["avatar_url"] = None payload["avatar_url"] = None
if target_user.id != viewer_user_id: if target_user.id != viewer_user_id:
payload["allow_private_messages"] = True payload["allow_private_messages"] = True
payload["privacy_private_messages"] = "everyone"
payload["privacy_last_seen"] = "everyone" payload["privacy_last_seen"] = "everyone"
payload["privacy_avatar"] = "everyone" payload["privacy_avatar"] = "everyone"
payload["privacy_group_invites"] = "everyone" payload["privacy_group_invites"] = "everyone"

View File

@@ -109,6 +109,19 @@ For `/health/ready` failure:
} }
``` ```
### SessionRead
```json
{
"jti": "uuid",
"created_at": "2026-03-08T10:00:00Z",
"ip_address": "127.0.0.1",
"user_agent": "Mozilla/5.0 ...",
"current": false,
"token_type": "refresh"
}
```
### AuthUserResponse ### AuthUserResponse
```json ```json
@@ -121,6 +134,11 @@ For `/health/ready` failure:
"avatar_url": "https://...", "avatar_url": "https://...",
"email_verified": true, "email_verified": true,
"twofa_enabled": false, "twofa_enabled": false,
"allow_private_messages": true,
"privacy_private_messages": "everyone",
"privacy_last_seen": "everyone",
"privacy_avatar": "everyone",
"privacy_group_invites": "everyone",
"created_at": "2026-03-08T10:00:00Z", "created_at": "2026-03-08T10:00:00Z",
"updated_at": "2026-03-08T10:00:00Z" "updated_at": "2026-03-08T10:00:00Z"
} }
@@ -140,6 +158,10 @@ For `/health/ready` failure:
"bio": "optional", "bio": "optional",
"email_verified": true, "email_verified": true,
"allow_private_messages": true, "allow_private_messages": true,
"privacy_private_messages": "everyone",
"privacy_last_seen": "everyone",
"privacy_avatar": "everyone",
"privacy_group_invites": "everyone",
"twofa_enabled": false, "twofa_enabled": false,
"created_at": "2026-03-08T10:00:00Z", "created_at": "2026-03-08T10:00:00Z",
"updated_at": "2026-03-08T10:00:00Z" "updated_at": "2026-03-08T10:00:00Z"
@@ -166,11 +188,16 @@ For `/health/ready` failure:
"username": "new_username", "username": "new_username",
"bio": "new bio", "bio": "new bio",
"avatar_url": "https://...", "avatar_url": "https://...",
"allow_private_messages": true "allow_private_messages": true,
"privacy_private_messages": "contacts",
"privacy_last_seen": "contacts",
"privacy_avatar": "everyone",
"privacy_group_invites": "contacts"
} }
``` ```
All fields are optional. All fields are optional.
`privacy_private_messages`: `everyone | contacts | nobody`.
## 3.3 Chats ## 3.3 Chats
@@ -501,6 +528,7 @@ Response: `200` + `AuthUserResponse`
Auth required. Auth required.
Response: `200` + `SessionRead[]` Response: `200` + `SessionRead[]`
Note: list includes refresh sessions and a synthetic current access-token session (`token_type=access`) for stable UI visibility.
### DELETE `/api/v1/auth/sessions/{jti}` ### DELETE `/api/v1/auth/sessions/{jti}`

View File

@@ -37,8 +37,8 @@ Legend:
28. Notifications - `PARTIAL` (browser notifications + mute/settings; no mobile push infra) 28. Notifications - `PARTIAL` (browser notifications + mute/settings; no mobile push infra)
29. Archive - `DONE` 29. Archive - `DONE`
30. Blacklist - `DONE` 30. Blacklist - `DONE`
31. Privacy - `PARTIAL` (PM permission + block; full matrix controls still limited) 31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; remaining edge UX/matrix hardening)
32. Security - `PARTIAL` (sessions + revoke + 2FA base; revoke-all now invalidates active access tokens, UX/TOTP flow ongoing) 32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; UX/TOTP recovery flow ongoing)
33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates) 33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates)
34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages) 34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages)
35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic)

View File

@@ -43,6 +43,15 @@ async def test_register_verify_login_and_me(client, db_session):
assert me_data["email"] == "alice@example.com" assert me_data["email"] == "alice@example.com"
assert me_data["email_verified"] is True assert me_data["email_verified"] is True
sessions_response = await client.get(
"/api/v1/auth/sessions",
headers={"Authorization": f"Bearer {token_data['access_token']}"},
)
assert sessions_response.status_code == 200
sessions = sessions_response.json()
assert len(sessions) >= 1
assert any(item.get("token_type") == "access" for item in sessions)
async def test_refresh_token_rotation(client, db_session): async def test_refresh_token_rotation(client, db_session):
payload = { payload = {

View File

@@ -59,3 +59,40 @@ async def test_private_chat_message_lifecycle(client, db_session):
headers={"Authorization": f"Bearer {u1['access_token']}"}, headers={"Authorization": f"Bearer {u1['access_token']}"},
) )
assert delete_message_response.status_code == 204 assert delete_message_response.status_code == 204
async def test_private_chat_respects_contacts_only_policy(client, db_session):
u1 = await _create_verified_user(client, db_session, "pm_u1@example.com", "pm_user_one", "strongpass123")
u2 = await _create_verified_user(client, db_session, "pm_u2@example.com", "pm_user_two", "strongpass123")
me_u1 = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {u1['access_token']}"})
me_u2 = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {u2['access_token']}"})
u1_id = me_u1.json()["id"]
u2_id = me_u2.json()["id"]
update_privacy = await client.put(
"/api/v1/users/profile",
headers={"Authorization": f"Bearer {u2['access_token']}"},
json={"privacy_private_messages": "contacts"},
)
assert update_privacy.status_code == 200
create_chat_blocked = await client.post(
"/api/v1/chats",
headers={"Authorization": f"Bearer {u1['access_token']}"},
json={"type": ChatType.PRIVATE.value, "title": None, "member_ids": [u2_id]},
)
assert create_chat_blocked.status_code == 403
add_contact = await client.post(
f"/api/v1/users/{u1_id}/contacts",
headers={"Authorization": f"Bearer {u2['access_token']}"},
)
assert add_contact.status_code == 204
create_chat_allowed = await client.post(
"/api/v1/chats",
headers={"Authorization": f"Bearer {u1['access_token']}"},
json={"type": ChatType.PRIVATE.value, "title": None, "member_ids": [u2_id]},
)
assert create_chat_allowed.status_code == 200

View File

@@ -14,6 +14,7 @@ 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_private_messages?: "everyone" | "contacts" | "nobody";
privacy_last_seen?: "everyone" | "contacts" | "nobody"; privacy_last_seen?: "everyone" | "contacts" | "nobody";
privacy_avatar?: "everyone" | "contacts" | "nobody"; privacy_avatar?: "everyone" | "contacts" | "nobody";
privacy_group_invites?: "everyone" | "contacts"; privacy_group_invites?: "everyone" | "contacts";

View File

@@ -83,6 +83,7 @@ export interface AuthUser {
email_verified: boolean; email_verified: boolean;
twofa_enabled?: boolean; twofa_enabled?: boolean;
allow_private_messages: boolean; allow_private_messages: boolean;
privacy_private_messages?: "everyone" | "contacts" | "nobody";
privacy_last_seen?: "everyone" | "contacts" | "nobody"; privacy_last_seen?: "everyone" | "contacts" | "nobody";
privacy_avatar?: "everyone" | "contacts" | "nobody"; privacy_avatar?: "everyone" | "contacts" | "nobody";
privacy_group_invites?: "everyone" | "contacts"; privacy_group_invites?: "everyone" | "contacts";
@@ -101,6 +102,8 @@ export interface AuthSession {
created_at: string; created_at: string;
ip_address?: string | null; ip_address?: string | null;
user_agent?: string | null; user_agent?: string | null;
current?: boolean;
token_type?: "access" | "refresh";
} }
export interface UserSearchItem { export interface UserSearchItem {

View File

@@ -26,7 +26,7 @@ export function SettingsPanel({ open, onClose }: Props) {
const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences()); const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences());
const [blockedCount, setBlockedCount] = useState(0); const [blockedCount, setBlockedCount] = useState(0);
const [savingPrivacy, setSavingPrivacy] = useState(false); const [savingPrivacy, setSavingPrivacy] = useState(false);
const [allowPrivateMessages, setAllowPrivateMessages] = useState(true); const [privacyPrivateMessages, setPrivacyPrivateMessages] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [sessions, setSessions] = useState<AuthSession[]>([]); const [sessions, setSessions] = useState<AuthSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsLoading, setSessionsLoading] = useState(false);
const [twofaCode, setTwofaCode] = useState(""); const [twofaCode, setTwofaCode] = useState("");
@@ -52,7 +52,7 @@ export function SettingsPanel({ open, onClose }: Props) {
if (!me) { if (!me) {
return; return;
} }
setAllowPrivateMessages(me.allow_private_messages); setPrivacyPrivateMessages(me.privacy_private_messages || (me.allow_private_messages ? "everyone" : "nobody"));
setPrivacyLastSeen(me.privacy_last_seen || "everyone"); setPrivacyLastSeen(me.privacy_last_seen || "everyone");
setPrivacyAvatar(me.privacy_avatar || "everyone"); setPrivacyAvatar(me.privacy_avatar || "everyone");
setPrivacyGroupInvites(me.privacy_group_invites || "everyone"); setPrivacyGroupInvites(me.privacy_group_invites || "everyone");
@@ -314,7 +314,13 @@ export function SettingsPanel({ open, onClose }: Props) {
/> />
<SettingsRow <SettingsRow
label="Privacy and Security" label="Privacy and Security"
value={allowPrivateMessages ? "Everybody can message" : "Messages disabled"} value={
privacyPrivateMessages === "everyone"
? "Everybody can message"
: privacyPrivateMessages === "contacts"
? "Only contacts can message"
: "Messages disabled"
}
onClick={() => setPage("privacy")} onClick={() => setPage("privacy")}
/> />
</section> </section>
@@ -417,6 +423,18 @@ 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)} />
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
<p className="mb-1 text-xs text-slate-300">Who can send me private messages?</p>
<select
className="w-full rounded bg-slate-800 px-2 py-1 text-xs"
value={privacyPrivateMessages}
onChange={(e) => setPrivacyPrivateMessages(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"> <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 profile photo?</p> <p className="mb-1 text-xs text-slate-300">Who can see my profile photo?</p>
<select <select
@@ -458,7 +476,8 @@ export function SettingsPanel({ open, onClose }: Props) {
setSavingPrivacy(true); setSavingPrivacy(true);
try { try {
const updated = await updateMyProfile({ const updated = await updateMyProfile({
allow_private_messages: allowPrivateMessages, allow_private_messages: privacyPrivateMessages !== "nobody",
privacy_private_messages: privacyPrivateMessages,
privacy_last_seen: privacyLastSeen, privacy_last_seen: privacyLastSeen,
privacy_avatar: privacyAvatar, privacy_avatar: privacyAvatar,
privacy_group_invites: privacyGroupInvites, privacy_group_invites: privacyGroupInvites,
@@ -586,9 +605,14 @@ export function SettingsPanel({ open, onClose }: Props) {
<div className="space-y-2"> <div className="space-y-2">
{sessions.map((session) => ( {sessions.map((session) => (
<div className="rounded bg-slate-900/50 px-2 py-2" key={session.jti}> <div className="rounded bg-slate-900/50 px-2 py-2" key={session.jti}>
<p className="truncate text-xs text-slate-300">{session.user_agent || "Unknown device"}</p> <p className="truncate text-xs text-slate-300">
{session.user_agent || "Unknown device"}
{session.current ? " (current)" : ""}
</p>
<p className="truncate text-[11px] text-slate-500">Token: {session.token_type || "refresh"}</p>
<p className="truncate text-[11px] text-slate-400">{session.ip_address || "Unknown IP"}</p> <p className="truncate text-[11px] text-slate-400">{session.ip_address || "Unknown IP"}</p>
<p className="text-[11px] text-slate-500">{new Date(session.created_at).toLocaleString()}</p> <p className="text-[11px] text-slate-500">{new Date(session.created_at).toLocaleString()}</p>
{session.token_type === "refresh" ? (
<button <button
className="mt-1 rounded bg-slate-700 px-2 py-1 text-[11px]" className="mt-1 rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={async () => { onClick={async () => {
@@ -599,6 +623,9 @@ export function SettingsPanel({ open, onClose }: Props) {
> >
Revoke Revoke
</button> </button>
) : (
<p className="mt-1 text-[11px] text-slate-500">Use Revoke all to invalidate active access token.</p>
)}
</div> </div>
))} ))}
</div> </div>