Merge feature/user-connections into main

User connections system: search by umbral name, send/accept/reject/cancel
requests, bidirectional Person records on accept, per-connection sharing
overrides, in-app notification centre with toast popups, ntfy push
integration. Includes QA fixes, pentest hardening, and contact sync fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-06 01:36:02 +08:00
commit 47645ec115
50 changed files with 3998 additions and 91 deletions

View File

@ -0,0 +1,37 @@
"""Add umbral_name to users table.
3-step migration: add nullable backfill from username alter to NOT NULL.
Backfill uses username || '_' || id as fallback if uniqueness conflicts arise.
Revision ID: 039
Revises: 038
"""
from alembic import op
import sqlalchemy as sa
revision = "039"
down_revision = "038"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Step 1: Add nullable column
op.add_column("users", sa.Column("umbral_name", sa.String(50), nullable=True))
# Step 2: Backfill from username (handles uniqueness conflicts with fallback)
op.execute("UPDATE users SET umbral_name = username")
# Fix any remaining NULLs (shouldn't happen, but defensive)
op.execute(
"UPDATE users SET umbral_name = username || '_' || id "
"WHERE umbral_name IS NULL"
)
# Step 3: Alter to NOT NULL and add unique index
op.alter_column("users", "umbral_name", nullable=False)
op.create_index("ix_users_umbral_name", "users", ["umbral_name"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_umbral_name", table_name="users")
op.drop_column("users", "umbral_name")

View File

@ -0,0 +1,85 @@
"""Expand settings with profile, social, and sharing fields.
Revision ID: 040
Revises: 039
"""
from alembic import op
import sqlalchemy as sa
revision = "040"
down_revision = "039"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Profile fields
op.add_column("settings", sa.Column("phone", sa.String(50), nullable=True))
op.add_column("settings", sa.Column("mobile", sa.String(50), nullable=True))
op.add_column("settings", sa.Column("address", sa.Text, nullable=True))
op.add_column("settings", sa.Column("company", sa.String(255), nullable=True))
op.add_column("settings", sa.Column("job_title", sa.String(255), nullable=True))
# Social toggle
op.add_column(
"settings",
sa.Column("accept_connections", sa.Boolean, nullable=False, server_default="false"),
)
# Sharing defaults
op.add_column(
"settings",
sa.Column("share_preferred_name", sa.Boolean, nullable=False, server_default="true"),
)
op.add_column(
"settings",
sa.Column("share_email", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_phone", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_mobile", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_birthday", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_address", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_company", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_job_title", sa.Boolean, nullable=False, server_default="false"),
)
# ntfy connection notifications toggle (gates push only, not in-app)
op.add_column(
"settings",
sa.Column("ntfy_connections_enabled", sa.Boolean, nullable=False, server_default="true"),
)
def downgrade() -> None:
op.drop_column("settings", "ntfy_connections_enabled")
op.drop_column("settings", "share_job_title")
op.drop_column("settings", "share_company")
op.drop_column("settings", "share_address")
op.drop_column("settings", "share_birthday")
op.drop_column("settings", "share_mobile")
op.drop_column("settings", "share_phone")
op.drop_column("settings", "share_email")
op.drop_column("settings", "share_preferred_name")
op.drop_column("settings", "accept_connections")
op.drop_column("settings", "job_title")
op.drop_column("settings", "company")
op.drop_column("settings", "address")
op.drop_column("settings", "mobile")
op.drop_column("settings", "phone")

View File

@ -0,0 +1,57 @@
"""Create notifications table for in-app notification centre.
Revision ID: 041
Revises: 040
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "041"
down_revision = "040"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", sa.Integer, primary_key=True, index=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("type", sa.String(50), nullable=False),
sa.Column("title", sa.String(255), nullable=True),
sa.Column("message", sa.Text, nullable=True),
sa.Column("data", JSONB, nullable=True),
sa.Column("source_type", sa.String(50), nullable=True),
sa.Column("source_id", sa.Integer, nullable=True),
sa.Column("is_read", sa.Boolean, nullable=False, server_default="false"),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
# Fast unread count query
op.execute(
'CREATE INDEX "ix_notifications_user_unread" ON notifications (user_id, is_read) '
"WHERE is_read = false"
)
# Paginated listing
op.create_index(
"ix_notifications_user_created",
"notifications",
["user_id", sa.text("created_at DESC")],
)
def downgrade() -> None:
op.drop_index("ix_notifications_user_created", table_name="notifications")
op.execute('DROP INDEX IF EXISTS "ix_notifications_user_unread"')
op.drop_table("notifications")

View File

@ -0,0 +1,109 @@
"""Create connection_requests and user_connections tables.
Revision ID: 042
Revises: 041
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "042"
down_revision = "041"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── connection_requests ──────────────────────────────────────────
op.create_table(
"connection_requests",
sa.Column("id", sa.Integer, primary_key=True, index=True),
sa.Column(
"sender_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"receiver_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"status",
sa.String(20),
nullable=False,
server_default="pending",
),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
sa.Column("resolved_at", sa.DateTime, nullable=True),
sa.CheckConstraint(
"status IN ('pending', 'accepted', 'rejected', 'cancelled')",
name="ck_connection_requests_status",
),
)
# Only one pending request per sender→receiver pair
op.execute(
'CREATE UNIQUE INDEX "ix_connection_requests_pending" '
"ON connection_requests (sender_id, receiver_id) "
"WHERE status = 'pending'"
)
# Incoming request listing
op.create_index(
"ix_connection_requests_receiver_status",
"connection_requests",
["receiver_id", "status"],
)
# Outgoing request listing
op.create_index(
"ix_connection_requests_sender_status",
"connection_requests",
["sender_id", "status"],
)
# ── user_connections ─────────────────────────────────────────────
op.create_table(
"user_connections",
sa.Column("id", sa.Integer, primary_key=True, index=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"connected_user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"person_id",
sa.Integer,
sa.ForeignKey("people.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("sharing_overrides", JSONB, nullable=True),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("user_id", "connected_user_id", name="uq_user_connections"),
)
def downgrade() -> None:
op.drop_table("user_connections")
op.drop_index("ix_connection_requests_sender_status", table_name="connection_requests")
op.drop_index("ix_connection_requests_receiver_status", table_name="connection_requests")
op.execute('DROP INDEX IF EXISTS "ix_connection_requests_pending"')
op.drop_table("connection_requests")

View File

@ -0,0 +1,44 @@
"""Add linked_user_id and is_umbral_contact to people table.
Revision ID: 043
Revises: 042
"""
from alembic import op
import sqlalchemy as sa
revision = "043"
down_revision = "042"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"people",
sa.Column(
"linked_user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column(
"people",
sa.Column(
"is_umbral_contact",
sa.Boolean,
nullable=False,
server_default="false",
),
)
# Fast lookup of umbral contacts by owner
op.execute(
'CREATE INDEX "ix_people_linked_user" ON people (user_id, linked_user_id) '
"WHERE linked_user_id IS NOT NULL"
)
def downgrade() -> None:
op.execute('DROP INDEX IF EXISTS "ix_people_linked_user"')
op.drop_column("people", "is_umbral_contact")
op.drop_column("people", "linked_user_id")

View File

@ -0,0 +1,45 @@
"""Add CHECK constraint on notifications.type column.
Revision ID: 044
Revises: 043
"""
from alembic import op
import sqlalchemy as sa
revision = "044"
down_revision = "043"
branch_labels = None
depends_on = None
ALLOWED_TYPES = (
"connection_request",
"connection_accepted",
"connection_rejected",
"info",
"warning",
"reminder",
"system",
)
def upgrade() -> None:
# Defensive: ensure no existing rows violate the constraint
conn = op.get_bind()
placeholders = ", ".join(f"'{t}'" for t in ALLOWED_TYPES)
bad = conn.execute(
sa.text(f"SELECT COUNT(*) FROM notifications WHERE type NOT IN ({placeholders})")
).scalar()
if bad:
raise RuntimeError(
f"Cannot apply CHECK constraint: {bad} notification(s) have types outside the allowed list"
)
op.create_check_constraint(
"ck_notifications_type",
"notifications",
f"type IN ({placeholders})",
)
def downgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")

View File

@ -0,0 +1,28 @@
"""Add share_first_name and share_last_name to settings.
Revision ID: 045
Revises: 044
"""
from alembic import op
import sqlalchemy as sa
revision = "045"
down_revision = "044"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"settings",
sa.Column("share_first_name", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_last_name", sa.Boolean, nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("settings", "share_last_name")
op.drop_column("settings", "share_first_name")

View File

@ -0,0 +1,34 @@
"""Add person_id to connection_requests
Revision ID: 046
Revises: 045
"""
from alembic import op
import sqlalchemy as sa
revision = "046"
down_revision = "045"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"connection_requests",
sa.Column(
"person_id",
sa.Integer(),
sa.ForeignKey("people.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"ix_connection_requests_person_id",
"connection_requests",
["person_id"],
)
def downgrade() -> None:
op.drop_index("ix_connection_requests_person_id", table_name="connection_requests")
op.drop_column("connection_requests", "person_id")

View File

@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload
from app.database import AsyncSessionLocal
from app.models.settings import Settings
from app.models.notification import Notification as AppNotification
from app.models.reminder import Reminder
from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
@ -25,6 +26,7 @@ from app.models.project import Project
from app.models.ntfy_sent import NtfySent
from app.models.totp_usage import TOTPUsage
from app.models.session import UserSession
from app.models.connection_request import ConnectionRequest
from app.services.ntfy import send_ntfy_notification
from app.services.ntfy_templates import (
build_event_notification,
@ -267,6 +269,37 @@ async def _purge_expired_sessions(db: AsyncSession) -> None:
await db.commit()
async def _purge_old_notifications(db: AsyncSession) -> None:
"""Remove in-app notifications older than 90 days."""
cutoff = datetime.now() - timedelta(days=90)
await db.execute(delete(AppNotification).where(AppNotification.created_at < cutoff))
await db.commit()
async def _purge_resolved_requests(db: AsyncSession) -> None:
"""Remove resolved connection requests after retention period.
Rejected/cancelled: 30 days. Accepted: 90 days (longer for audit trail).
resolved_at must be set when changing status. NULL resolved_at rows are
preserved (comparison with NULL yields NULL).
"""
reject_cutoff = datetime.now() - timedelta(days=30)
accept_cutoff = datetime.now() - timedelta(days=90)
await db.execute(
delete(ConnectionRequest).where(
ConnectionRequest.status.in_(["rejected", "cancelled"]),
ConnectionRequest.resolved_at < reject_cutoff,
)
)
await db.execute(
delete(ConnectionRequest).where(
ConnectionRequest.status == "accepted",
ConnectionRequest.resolved_at < accept_cutoff,
)
)
await db.commit()
# ── Entry point ───────────────────────────────────────────────────────────────
async def run_notification_dispatch() -> None:
@ -308,6 +341,8 @@ async def run_notification_dispatch() -> None:
async with AsyncSessionLocal() as db:
await _purge_totp_usage(db)
await _purge_expired_sessions(db)
await _purge_old_notifications(db)
await _purge_resolved_requests(db)
except Exception:
# Broad catch: job failure must never crash the scheduler or the app

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings
from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.routers import totp, admin
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router
from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them
@ -17,6 +17,9 @@ from app.models import totp_usage as _totp_usage_model # noqa: F401
from app.models import backup_code as _backup_code_model # noqa: F401
from app.models import system_config as _system_config_model # noqa: F401
from app.models import audit_log as _audit_log_model # noqa: F401
from app.models import notification as _notification_model # noqa: F401
from app.models import connection_request as _connection_request_model # noqa: F401
from app.models import user_connection as _user_connection_model # noqa: F401
# ---------------------------------------------------------------------------
@ -129,6 +132,8 @@ app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
@app.get("/")

View File

@ -15,6 +15,9 @@ from app.models.totp_usage import TOTPUsage
from app.models.backup_code import BackupCode
from app.models.system_config import SystemConfig
from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.connection_request import ConnectionRequest
from app.models.user_connection import UserConnection
__all__ = [
"Settings",
@ -34,4 +37,7 @@ __all__ = [
"BackupCode",
"SystemConfig",
"AuditLog",
"Notification",
"ConnectionRequest",
"UserConnection",
]

View File

@ -0,0 +1,36 @@
from sqlalchemy import String, Integer, ForeignKey, CheckConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from app.database import Base
if TYPE_CHECKING:
from app.models.user import User
class ConnectionRequest(Base):
__tablename__ = "connection_requests"
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'accepted', 'rejected', 'cancelled')",
name="ck_connection_requests_status",
),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
sender_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
receiver_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending")
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
resolved_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, default=None)
person_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True
)
# Relationships with explicit foreign_keys to disambiguate
sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin")
receiver: Mapped["User"] = relationship(foreign_keys=[receiver_id], lazy="selectin")

View File

@ -0,0 +1,36 @@
from sqlalchemy import CheckConstraint, String, Text, Integer, Boolean, ForeignKey, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from app.database import Base
# Active: connection_request, connection_accepted
# Reserved: connection_rejected, info, warning, reminder, system
_NOTIFICATION_TYPES = (
"connection_request", "connection_accepted", "connection_rejected",
"info", "warning", "reminder", "system",
)
class Notification(Base):
__tablename__ = "notifications"
__table_args__ = (
CheckConstraint(
f"type IN ({', '.join(repr(t) for t in _NOTIFICATION_TYPES)})",
name="ck_notifications_type",
),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
type: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
source_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
is_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())

View File

@ -27,6 +27,11 @@ class Person(Base):
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Umbral contact link
linked_user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
is_umbral_contact: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text('false'))
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, func
from sqlalchemy import String, Text, Integer, Float, Boolean, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
@ -46,6 +46,31 @@ class Settings(Base):
auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
# Profile fields (shareable with connections)
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
address: Mapped[Optional[str]] = mapped_column(Text, nullable=True, default=None)
company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
# Social settings
accept_connections: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# Sharing defaults (what fields are shared with connections by default)
share_first_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_last_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_preferred_name: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
share_email: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_phone: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_mobile: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_birthday: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_address: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_company: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_job_title: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# ntfy connection notification toggle (gates push only, not in-app)
ntfy_connections_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
@property
def ntfy_has_token(self) -> bool:
"""Derived field for SettingsResponse — True when an auth token is stored."""

View File

@ -9,6 +9,7 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
umbral_name: Mapped[str] = mapped_column(String(50), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)

View File

@ -0,0 +1,31 @@
from sqlalchemy import Integer, ForeignKey, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from app.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.person import Person
class UserConnection(Base):
__tablename__ = "user_connections"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
connected_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
person_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True
)
sharing_overrides: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
# Relationships
connected_user: Mapped["User"] = relationship(foreign_keys=[connected_user_id], lazy="selectin")
person: Mapped[Optional["Person"]] = relationship(foreign_keys=[person_id], lazy="selectin")

View File

@ -70,10 +70,21 @@ def _target_username_col(target_alias, audit_model):
COALESCE: prefer the live username from the users table,
fall back to the username stored in the audit detail JSON
(survives user deletion since audit_log.target_user_id SET NULL).
Guard the JSONB cast with a CASE to avoid errors on non-JSON detail values.
"""
json_fallback = sa.case(
(
sa.and_(
audit_model.detail.is_not(None),
audit_model.detail.startswith("{"),
),
sa.cast(audit_model.detail, JSONB)["username"].as_string(),
),
else_=sa.null(),
)
return sa.func.coalesce(
target_alias.username,
sa.cast(audit_model.detail, JSONB)["username"].as_string(),
json_fallback,
).label("target_username")
@ -170,9 +181,9 @@ async def get_user(
)
active_sessions = session_result.scalar_one()
# Fetch preferred_name from Settings
# Fetch preferred_name from Settings (limit 1 defensive)
settings_result = await db.execute(
sa.select(Settings.preferred_name).where(Settings.user_id == user_id)
sa.select(Settings.preferred_name).where(Settings.user_id == user_id).limit(1)
)
preferred_name = settings_result.scalar_one_or_none()
@ -181,6 +192,8 @@ async def get_user(
active_sessions=active_sessions,
preferred_name=preferred_name,
date_of_birth=user.date_of_birth,
must_change_password=user.must_change_password,
locked_until=user.locked_until,
)
@ -209,6 +222,7 @@ async def create_user(
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=hash_password(data.password),
role=data.role,
email=email,
@ -241,6 +255,10 @@ async def create_user(
return UserDetailResponse(
**UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}),
active_sessions=0,
preferred_name=data.preferred_name,
date_of_birth=None,
must_change_password=new_user.must_change_password,
locked_until=new_user.locked_until,
)

View File

@ -288,6 +288,7 @@ async def setup(
password_hash = hash_password(data.password)
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=password_hash,
role="admin",
last_password_change_at=datetime.now(),
@ -440,7 +441,7 @@ async def register(
select(User).where(User.username == data.username)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.")
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
# Check email uniqueness (generic error to prevent enumeration)
if data.email:
@ -454,6 +455,7 @@ async def register(
# SEC-01: Explicit field assignment — never **data.model_dump()
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=password_hash,
role="standard",
email=data.email,
@ -666,6 +668,15 @@ async def update_profile(
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email is already in use")
# Umbral name uniqueness check if changing
if "umbral_name" in update_data and update_data["umbral_name"] != current_user.umbral_name:
new_name = update_data["umbral_name"]
existing = await db.execute(
select(User).where(User.umbral_name == new_name, User.id != current_user.id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Umbral name is already taken")
# SEC-01: Explicit field assignment — only allowed profile fields
if "first_name" in update_data:
current_user.first_name = update_data["first_name"]
@ -675,6 +686,8 @@ async def update_profile(
current_user.email = update_data["email"]
if "date_of_birth" in update_data:
current_user.date_of_birth = update_data["date_of_birth"]
if "umbral_name" in update_data:
current_user.umbral_name = update_data["umbral_name"]
await log_audit_event(
db, action="auth.profile_updated", actor_id=current_user.id,

View File

@ -0,0 +1,836 @@
"""
Connection router search, request, respond, manage connections.
Security:
- Timing-safe search (50ms sleep floor)
- Per-receiver pending request cap (5 within 10 minutes)
- Atomic accept via UPDATE...WHERE status='pending' RETURNING *
- All endpoints scoped by current_user.id
- Audit logging for all connection events
"""
import asyncio
import logging
from datetime import date as date_type, datetime, timedelta
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request
from sqlalchemy import delete, select, func, and_, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.connection_request import ConnectionRequest
from app.models.notification import Notification
from app.models.person import Person
from app.models.settings import Settings
from app.models.user import User
from app.models.user_connection import UserConnection
from app.routers.auth import get_current_user
from app.schemas.connection import (
CancelResponse,
ConnectionRequestResponse,
ConnectionResponse,
RespondAcceptResponse,
RespondRejectResponse,
RespondRequest,
SendConnectionRequest,
SharingOverrideUpdate,
UmbralSearchRequest,
UmbralSearchResponse,
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.connection import (
NOTIF_TYPE_CONNECTION_ACCEPTED,
NOTIF_TYPE_CONNECTION_REQUEST,
SHAREABLE_FIELDS,
create_person_from_connection,
detach_umbral_contact,
extract_ntfy_config,
resolve_shared_profile,
send_connection_ntfy,
)
from app.services.notification import create_notification
router = APIRouter()
logger = logging.getLogger(__name__)
# ── Helpers ──────────────────────────────────────────────────────────
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
return result.scalar_one_or_none()
def _build_request_response(
req: ConnectionRequest,
sender: User,
sender_settings: Settings | None,
receiver: User,
receiver_settings: Settings | None,
) -> ConnectionRequestResponse:
return ConnectionRequestResponse(
id=req.id,
sender_umbral_name=sender.umbral_name,
sender_preferred_name=sender_settings.preferred_name if sender_settings else None,
receiver_umbral_name=receiver.umbral_name,
receiver_preferred_name=receiver_settings.preferred_name if receiver_settings else None,
status=req.status,
created_at=req.created_at,
)
# ── POST /search ────────────────────────────────────────────────────
@router.post("/search", response_model=UmbralSearchResponse)
async def search_user(
body: UmbralSearchRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Timing-safe user search. Always queries by umbral_name alone,
then checks accept_connections + is_active in Python.
Generic "not found" for non-existent, opted-out, AND inactive users.
50ms sleep floor to eliminate timing side-channel.
"""
# Always sleep to prevent timing attacks
await asyncio.sleep(0.05)
# Sender must have accept_connections enabled to search
sender_settings = await _get_settings_for_user(db, current_user.id)
if not sender_settings or not sender_settings.accept_connections:
return UmbralSearchResponse(found=False)
# Don't find yourself
if body.umbral_name == current_user.umbral_name:
return UmbralSearchResponse(found=False)
result = await db.execute(
select(User).where(User.umbral_name == body.umbral_name)
)
target = result.scalar_one_or_none()
if not target or not target.is_active:
return UmbralSearchResponse(found=False)
# Check if they accept connections
target_settings = await _get_settings_for_user(db, target.id)
if not target_settings or not target_settings.accept_connections:
return UmbralSearchResponse(found=False)
return UmbralSearchResponse(found=True)
# ── POST /request ───────────────────────────────────────────────────
@router.post("/request", response_model=ConnectionRequestResponse, status_code=201)
async def send_connection_request(
body: SendConnectionRequest,
request: Request,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Send a connection request to another user."""
# Resolve target
result = await db.execute(
select(User).where(User.umbral_name == body.umbral_name)
)
target = result.scalar_one_or_none()
if not target or not target.is_active:
raise HTTPException(status_code=404, detail="User not found")
# Self-request guard
if target.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot send a connection request to yourself")
# Sender must have accept_connections enabled to participate
sender_settings = await _get_settings_for_user(db, current_user.id)
if not sender_settings or not sender_settings.accept_connections:
raise HTTPException(
status_code=403,
detail="You must enable 'Accept Connections' in your settings before sending requests",
)
# Check accept_connections on target
target_settings = await _get_settings_for_user(db, target.id)
if not target_settings or not target_settings.accept_connections:
raise HTTPException(status_code=404, detail="User not found")
# Check existing connection
existing_conn = await db.execute(
select(UserConnection).where(
UserConnection.user_id == current_user.id,
UserConnection.connected_user_id == target.id,
)
)
if existing_conn.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Already connected")
# Check pending request in either direction
existing_req = await db.execute(
select(ConnectionRequest).where(
and_(
ConnectionRequest.status == "pending",
(
(ConnectionRequest.sender_id == current_user.id) & (ConnectionRequest.receiver_id == target.id)
) | (
(ConnectionRequest.sender_id == target.id) & (ConnectionRequest.receiver_id == current_user.id)
),
)
)
)
if existing_req.scalar_one_or_none():
raise HTTPException(status_code=409, detail="A pending request already exists")
# Per-receiver cap: max 5 pending requests within 10 minutes
ten_min_ago = datetime.now() - timedelta(minutes=10)
pending_count = await db.scalar(
select(func.count())
.select_from(ConnectionRequest)
.where(
ConnectionRequest.receiver_id == target.id,
ConnectionRequest.status == "pending",
ConnectionRequest.created_at >= ten_min_ago,
)
) or 0
if pending_count >= 5:
raise HTTPException(status_code=429, detail="Too many pending requests for this user")
# Validate person_id if provided (link existing standard contact)
link_person_id = None
if body.person_id is not None:
person_result = await db.execute(
select(Person).where(Person.id == body.person_id, Person.user_id == current_user.id)
)
link_person = person_result.scalar_one_or_none()
if not link_person:
raise HTTPException(status_code=400, detail="Person not found or not owned by you")
if link_person.is_umbral_contact:
raise HTTPException(status_code=400, detail="Person is already an umbral contact")
link_person_id = body.person_id
# Create the request (IntegrityError guard for TOCTOU race on partial unique index)
conn_request = ConnectionRequest(
sender_id=current_user.id,
receiver_id=target.id,
person_id=link_person_id,
)
db.add(conn_request)
try:
await db.flush() # populate conn_request.id for source_id
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="A pending request already exists")
# Create in-app notification for receiver (sender_settings already fetched above)
sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=target.id,
type=NOTIF_TYPE_CONNECTION_REQUEST,
title="New Connection Request",
message=f"{sender_display} wants to connect with you",
data={"sender_umbral_name": current_user.umbral_name},
source_type=NOTIF_TYPE_CONNECTION_REQUEST,
source_id=conn_request.id,
)
await log_audit_event(
db,
action="connection.request_sent",
actor_id=current_user.id,
target_id=target.id,
detail={"receiver_umbral_name": target.umbral_name},
ip=get_client_ip(request),
)
# Extract ntfy config before commit (avoids detached SA object in background task)
target_ntfy = extract_ntfy_config(target_settings) if target_settings else None
# Build response BEFORE commit — commit expires all ORM objects, and accessing
# their attributes after commit triggers lazy loads → MissingGreenlet in async SA.
response = _build_request_response(conn_request, current_user, sender_settings, target, target_settings)
await db.commit()
# ntfy push in background (non-blocking)
background_tasks.add_task(
send_connection_ntfy,
target_ntfy,
sender_display,
"request_received",
)
return response
# ── GET /requests/incoming ──────────────────────────────────────────
@router.get("/requests/incoming", response_model=list[ConnectionRequestResponse])
async def get_incoming_requests(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List pending connection requests received by the current user."""
offset = (page - 1) * per_page
result = await db.execute(
select(ConnectionRequest)
.where(
ConnectionRequest.receiver_id == current_user.id,
ConnectionRequest.status == "pending",
)
.options(selectinload(ConnectionRequest.sender))
.order_by(ConnectionRequest.created_at.desc())
.offset(offset)
.limit(per_page)
)
requests = result.scalars().all()
# Fetch current user's settings once, batch-fetch sender settings
receiver_settings = await _get_settings_for_user(db, current_user.id)
sender_ids = [req.sender_id for req in requests]
if sender_ids:
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(sender_ids)))
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
else:
settings_by_user = {}
responses = []
for req in requests:
sender_settings = settings_by_user.get(req.sender_id)
responses.append(_build_request_response(req, req.sender, sender_settings, current_user, receiver_settings))
return responses
# ── GET /requests/outgoing ──────────────────────────────────────────
@router.get("/requests/outgoing", response_model=list[ConnectionRequestResponse])
async def get_outgoing_requests(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List pending connection requests sent by the current user."""
offset = (page - 1) * per_page
result = await db.execute(
select(ConnectionRequest)
.where(
ConnectionRequest.sender_id == current_user.id,
ConnectionRequest.status == "pending",
)
.options(selectinload(ConnectionRequest.receiver))
.order_by(ConnectionRequest.created_at.desc())
.offset(offset)
.limit(per_page)
)
requests = result.scalars().all()
# Fetch current user's settings once, batch-fetch receiver settings
sender_settings = await _get_settings_for_user(db, current_user.id)
receiver_ids = [req.receiver_id for req in requests]
if receiver_ids:
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(receiver_ids)))
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
else:
settings_by_user = {}
responses = []
for req in requests:
receiver_settings = settings_by_user.get(req.receiver_id)
responses.append(_build_request_response(req, current_user, sender_settings, req.receiver, receiver_settings))
return responses
# ── PUT /requests/{id}/respond ──────────────────────────────────────
@router.put("/requests/{request_id}/respond", response_model=RespondAcceptResponse | RespondRejectResponse)
async def respond_to_request(
body: RespondRequest,
request: Request,
background_tasks: BackgroundTasks,
request_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept or reject a connection request. Atomic via UPDATE...WHERE status='pending'."""
try:
return await _respond_to_request_inner(body, request, background_tasks, request_id, db, current_user)
except HTTPException:
raise
except Exception:
# get_db middleware auto-rollbacks on unhandled exceptions
logger.exception("Unhandled error in respond_to_request (request_id=%s, user=%s)", request_id, current_user.id)
raise HTTPException(status_code=500, detail=f"Internal server error while processing connection response (request {request_id})")
async def _respond_to_request_inner(
body: RespondRequest,
request: Request,
background_tasks: BackgroundTasks,
request_id: int,
db: AsyncSession,
current_user: User,
) -> RespondAcceptResponse | RespondRejectResponse:
now = datetime.now()
# Atomic update — only succeeds if status is still 'pending' and receiver is current user
result = await db.execute(
update(ConnectionRequest)
.where(
ConnectionRequest.id == request_id,
ConnectionRequest.receiver_id == current_user.id,
ConnectionRequest.status == "pending",
)
.values(status=body.action + "ed", resolved_at=now)
.returning(
ConnectionRequest.id,
ConnectionRequest.sender_id,
ConnectionRequest.receiver_id,
ConnectionRequest.person_id,
)
)
row = result.first()
if not row:
raise HTTPException(status_code=409, detail="Request not found or already resolved")
sender_id = row.sender_id
request_person_id = row.person_id
if body.action == "accept":
# Verify sender is still active
sender_result = await db.execute(select(User).where(User.id == sender_id))
sender = sender_result.scalar_one_or_none()
if not sender or not sender.is_active:
# Revert to rejected
await db.execute(
update(ConnectionRequest)
.where(ConnectionRequest.id == request_id)
.values(status="rejected")
)
await db.commit()
raise HTTPException(status_code=409, detail="Sender account is no longer active")
# Get settings for both users
sender_settings = await _get_settings_for_user(db, sender_id)
receiver_settings = await _get_settings_for_user(db, current_user.id)
# Resolve shared profiles for both directions
sender_shared = resolve_shared_profile(sender, sender_settings, None) if sender_settings else {}
receiver_shared = resolve_shared_profile(current_user, receiver_settings, None) if receiver_settings else {}
# Create Person records for both users
person_for_receiver = create_person_from_connection(
current_user.id, sender, sender_settings, sender_shared
)
db.add(person_for_receiver)
# Sender side: reuse existing Person if person_id was provided on the request
person_for_sender = None
if request_person_id:
existing_result = await db.execute(
select(Person).where(Person.id == request_person_id)
)
existing_person = existing_result.scalar_one_or_none()
# Re-validate at accept time: ownership must match sender,
# and must not already be umbral (prevents double-conversion races)
if existing_person and existing_person.user_id == sender_id and not existing_person.is_umbral_contact:
# Convert existing standard contact to umbral
existing_person.linked_user_id = current_user.id
existing_person.is_umbral_contact = True
existing_person.category = "Umbral"
# Update from shared profile
first_name = receiver_shared.get("first_name") or receiver_shared.get("preferred_name") or current_user.umbral_name
last_name = receiver_shared.get("last_name")
existing_person.first_name = first_name
existing_person.last_name = last_name
existing_person.email = receiver_shared.get("email") or existing_person.email
existing_person.phone = receiver_shared.get("phone") or existing_person.phone
existing_person.mobile = receiver_shared.get("mobile") or existing_person.mobile
existing_person.address = receiver_shared.get("address") or existing_person.address
existing_person.company = receiver_shared.get("company") or existing_person.company
existing_person.job_title = receiver_shared.get("job_title") or existing_person.job_title
# Sync birthday from shared profile
birthday_str = receiver_shared.get("birthday")
if birthday_str:
try:
existing_person.birthday = date_type.fromisoformat(birthday_str)
except (ValueError, TypeError):
pass
# Recompute display name
full = ((first_name or '') + ' ' + (last_name or '')).strip()
existing_person.name = full or current_user.umbral_name
person_for_sender = existing_person
if person_for_sender is None:
person_for_sender = create_person_from_connection(
sender_id, current_user, receiver_settings, receiver_shared
)
db.add(person_for_sender)
try:
await db.flush() # populate person IDs
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="Connection already exists")
# Create bidirectional connections
conn_a = UserConnection(
user_id=current_user.id,
connected_user_id=sender_id,
person_id=person_for_receiver.id,
)
conn_b = UserConnection(
user_id=sender_id,
connected_user_id=current_user.id,
person_id=person_for_sender.id,
)
db.add(conn_a)
db.add(conn_b)
try:
await db.flush() # populate conn_a.id for source_id
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="Connection already exists")
# Notification to sender
receiver_display = (receiver_settings.preferred_name if receiver_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=sender_id,
type=NOTIF_TYPE_CONNECTION_ACCEPTED,
title="Connection Accepted",
message=f"{receiver_display} accepted your connection request",
data={"connected_umbral_name": current_user.umbral_name},
source_type="user_connection",
source_id=conn_b.id,
)
await log_audit_event(
db,
action="connection.accepted",
actor_id=current_user.id,
target_id=sender_id,
detail={"request_id": request_id},
ip=get_client_ip(request),
)
# Extract ntfy config before commit (avoids detached SA object in background task)
sender_ntfy = extract_ntfy_config(sender_settings) if sender_settings else None
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="Connection already exists")
# ntfy push in background
background_tasks.add_task(
send_connection_ntfy,
sender_ntfy,
receiver_display,
"request_accepted",
)
return {"message": "Connection accepted", "connection_id": conn_a.id}
else:
# Reject — only create notification for receiver (not sender per plan)
await log_audit_event(
db,
action="connection.rejected",
actor_id=current_user.id,
target_id=sender_id,
detail={"request_id": request_id},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Connection request rejected"}
# ── PUT /requests/{id}/cancel ──────────────────────────────────────
@router.put("/requests/{request_id}/cancel", response_model=CancelResponse)
async def cancel_request(
request: Request,
request_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cancel an outgoing connection request. Atomic via UPDATE...WHERE status='pending'."""
now = datetime.now()
# Atomic update — only succeeds if sender is current user and status is still pending
result = await db.execute(
update(ConnectionRequest)
.where(
ConnectionRequest.id == request_id,
ConnectionRequest.sender_id == current_user.id,
ConnectionRequest.status == "pending",
)
.values(status="cancelled", resolved_at=now)
.returning(ConnectionRequest.id, ConnectionRequest.receiver_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=409, detail="Request not found or already resolved")
receiver_id = row.receiver_id
# Silent cleanup: remove the notification sent to the receiver
await db.execute(
delete(Notification).where(
Notification.source_type == NOTIF_TYPE_CONNECTION_REQUEST,
Notification.source_id == request_id,
Notification.user_id == receiver_id,
)
)
# Look up receiver umbral_name for audit detail
receiver_result = await db.execute(select(User.umbral_name).where(User.id == receiver_id))
receiver_umbral_name = receiver_result.scalar_one_or_none() or "unknown"
await log_audit_event(
db,
action="connection.request_cancelled",
actor_id=current_user.id,
target_id=receiver_id,
detail={"request_id": request_id, "receiver_umbral_name": receiver_umbral_name},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Connection request cancelled"}
# ── GET / ───────────────────────────────────────────────────────────
@router.get("/", response_model=list[ConnectionResponse])
async def list_connections(
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all connections for the current user."""
offset = (page - 1) * per_page
result = await db.execute(
select(UserConnection)
.where(UserConnection.user_id == current_user.id)
.options(selectinload(UserConnection.connected_user))
.order_by(UserConnection.created_at.desc())
.offset(offset)
.limit(per_page)
)
connections = result.scalars().all()
# Batch-fetch settings for connected users
connected_ids = [conn.connected_user_id for conn in connections]
if connected_ids:
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(connected_ids)))
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
else:
settings_by_user = {}
responses = []
for conn in connections:
conn_settings = settings_by_user.get(conn.connected_user_id)
responses.append(ConnectionResponse(
id=conn.id,
connected_user_id=conn.connected_user_id,
connected_umbral_name=conn.connected_user.umbral_name,
connected_preferred_name=conn_settings.preferred_name if conn_settings else None,
person_id=conn.person_id,
created_at=conn.created_at,
))
return responses
# ── GET /{id} ───────────────────────────────────────────────────────
@router.get("/{connection_id}", response_model=ConnectionResponse)
async def get_connection(
connection_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single connection detail."""
result = await db.execute(
select(UserConnection)
.where(
UserConnection.id == connection_id,
UserConnection.user_id == current_user.id,
)
.options(selectinload(UserConnection.connected_user))
)
conn = result.scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
return ConnectionResponse(
id=conn.id,
connected_user_id=conn.connected_user_id,
connected_umbral_name=conn.connected_user.umbral_name,
connected_preferred_name=conn_settings.preferred_name if conn_settings else None,
person_id=conn.person_id,
created_at=conn.created_at,
)
# ── GET /{id}/shared-profile ────────────────────────────────────────
@router.get("/{connection_id}/shared-profile")
async def get_shared_profile(
connection_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get the resolved shared profile for a connection."""
result = await db.execute(
select(UserConnection)
.where(
UserConnection.id == connection_id,
UserConnection.user_id == current_user.id,
)
.options(selectinload(UserConnection.connected_user))
)
conn = result.scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
if not conn_settings:
return {}
return resolve_shared_profile(
conn.connected_user,
conn_settings,
conn.sharing_overrides,
)
# ── PUT /{id}/sharing-overrides ─────────────────────────────────────
@router.put("/{connection_id}/sharing-overrides")
async def update_sharing_overrides(
body: SharingOverrideUpdate,
connection_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update what YOU share with a specific connection."""
# Get our connection to know who the counterpart is
our_conn = await db.execute(
select(UserConnection).where(
UserConnection.id == connection_id,
UserConnection.user_id == current_user.id,
)
)
conn = our_conn.scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
# Find the reverse connection (their row pointing to us)
reverse_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == conn.connected_user_id,
UserConnection.connected_user_id == current_user.id,
)
)
reverse_conn = reverse_result.scalar_one_or_none()
if not reverse_conn:
raise HTTPException(status_code=404, detail="Reverse connection not found")
# Merge validated overrides — only SHAREABLE_FIELDS keys
existing = dict(reverse_conn.sharing_overrides or {})
update_data = body.model_dump(exclude_unset=True)
for key, value in update_data.items():
if key in SHAREABLE_FIELDS:
if value is None:
existing.pop(key, None)
else:
existing[key] = value
reverse_conn.sharing_overrides = existing if existing else None
await db.commit()
return {"message": "Sharing overrides updated"}
# ── DELETE /{id} ────────────────────────────────────────────────────
@router.delete("/{connection_id}", status_code=204)
async def remove_connection(
request: Request,
connection_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Remove a connection. Removes BOTH UserConnection rows.
Detaches BOTH Person records (sets linked_user_id=null, is_umbral_contact=false).
Silent no notification sent.
"""
# Get our connection
result = await db.execute(
select(UserConnection)
.where(
UserConnection.id == connection_id,
UserConnection.user_id == current_user.id,
)
)
conn = result.scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
counterpart_id = conn.connected_user_id
# Find reverse connection
reverse_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == counterpart_id,
UserConnection.connected_user_id == current_user.id,
)
)
reverse_conn = reverse_result.scalar_one_or_none()
# Detach Person records
if conn.person_id:
person_result = await db.execute(select(Person).where(Person.id == conn.person_id))
person = person_result.scalar_one_or_none()
if person:
await detach_umbral_contact(person)
if reverse_conn and reverse_conn.person_id:
person_result = await db.execute(select(Person).where(Person.id == reverse_conn.person_id))
person = person_result.scalar_one_or_none()
if person:
await detach_umbral_contact(person)
# Delete both connections
await db.delete(conn)
if reverse_conn:
await db.delete(reverse_conn)
await log_audit_event(
db,
action="connection.removed",
actor_id=current_user.id,
target_id=counterpart_id,
detail={"connection_id": connection_id},
ip=get_client_ip(request),
)
await db.commit()
return None

View File

@ -0,0 +1,143 @@
"""
Notification centre router in-app notifications.
All endpoints scoped by current_user.id to prevent IDOR.
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy import select, func, update, delete, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.notification import Notification
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.notification import (
NotificationResponse,
NotificationListResponse,
MarkReadRequest,
)
router = APIRouter()
@router.get("/", response_model=NotificationListResponse)
async def list_notifications(
unread_only: bool = Query(False),
notification_type: str | None = Query(None, max_length=50, alias="type"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Paginated notification list with optional filters."""
base = select(Notification).where(Notification.user_id == current_user.id)
if unread_only:
base = base.where(Notification.is_read == False) # noqa: E712
if notification_type:
base = base.where(Notification.type == notification_type)
# Total count
count_q = select(func.count()).select_from(base.subquery())
total = await db.scalar(count_q) or 0
# Unread count (always full, regardless of filters)
unread_count = await db.scalar(
select(func.count())
.select_from(Notification)
.where(
Notification.user_id == current_user.id,
Notification.is_read == False, # noqa: E712
)
) or 0
# Paginated results
offset = (page - 1) * per_page
result = await db.execute(
base.order_by(Notification.created_at.desc()).offset(offset).limit(per_page)
)
notifications = result.scalars().all()
return NotificationListResponse(
notifications=notifications,
unread_count=unread_count,
total=total,
)
@router.get("/unread-count")
async def get_unread_count(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Lightweight unread count endpoint (uses partial index)."""
count = await db.scalar(
select(func.count())
.select_from(Notification)
.where(
Notification.user_id == current_user.id,
Notification.is_read == False, # noqa: E712
)
) or 0
return {"count": count}
@router.put("/read")
async def mark_read(
body: MarkReadRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Mark specific notification IDs as read (user_id scoped — IDOR prevention)."""
await db.execute(
update(Notification)
.where(
and_(
Notification.id.in_(body.notification_ids),
Notification.user_id == current_user.id,
)
)
.values(is_read=True)
)
await db.commit()
return {"message": "Notifications marked as read"}
@router.put("/read-all")
async def mark_all_read(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Mark all notifications as read for current user."""
await db.execute(
update(Notification)
.where(
Notification.user_id == current_user.id,
Notification.is_read == False, # noqa: E712
)
.values(is_read=True)
)
await db.commit()
return {"message": "All notifications marked as read"}
@router.delete("/{notification_id}", status_code=204)
async def delete_notification(
notification_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a single notification (user_id scoped)."""
result = await db.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == current_user.id,
)
)
notification = result.scalar_one_or_none()
if not notification:
raise HTTPException(status_code=404, detail="Notification not found")
await db.delete(notification)
await db.commit()
return None

View File

@ -1,14 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
from typing import Optional, List
from app.database import get_db
from app.models.person import Person
from app.models.settings import Settings
from app.models.user import User
from app.models.user_connection import UserConnection
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
from app.routers.auth import get_current_user
from app.models.user import User
from app.services.connection import detach_umbral_contact, resolve_shared_profile
router = APIRouter()
@ -59,6 +63,62 @@ async def get_people(
result = await db.execute(query)
people = result.scalars().all()
# Batch-load shared profiles for umbral contacts
umbral_people = [p for p in people if p.linked_user_id is not None]
if umbral_people:
linked_user_ids = [p.linked_user_id for p in umbral_people]
# Batch fetch users and settings
users_result = await db.execute(
select(User).where(User.id.in_(linked_user_ids))
)
users_by_id = {u.id: u for u in users_result.scalars().all()}
settings_result = await db.execute(
select(Settings).where(Settings.user_id.in_(linked_user_ids))
)
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
# Batch fetch connection overrides
conns_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == current_user.id,
UserConnection.connected_user_id.in_(linked_user_ids),
)
)
overrides_by_user = {
c.connected_user_id: c.sharing_overrides
for c in conns_result.scalars().all()
}
# Build shared profiles and track remote timestamps separately
shared_profiles: dict[int, dict] = {}
remote_timestamps: dict[int, datetime] = {}
for uid in linked_user_ids:
user = users_by_id.get(uid)
user_settings = settings_by_user.get(uid)
if user and user_settings:
shared_profiles[uid] = resolve_shared_profile(
user, user_settings, overrides_by_user.get(uid)
)
# umbral_name is always visible (public identity), not a shareable field
shared_profiles[uid]["umbral_name"] = user.umbral_name
if user.updated_at and user_settings.updated_at:
remote_timestamps[uid] = max(user.updated_at, user_settings.updated_at)
# Attach to response
responses = []
for p in people:
resp = PersonResponse.model_validate(p)
if p.linked_user_id and p.linked_user_id in shared_profiles:
resp.shared_fields = shared_profiles[p.linked_user_id]
# Show the latest update time across local record and connected user's profile
remote_updated = remote_timestamps.get(p.linked_user_id)
if remote_updated and remote_updated > p.updated_at:
resp.updated_at = remote_updated
responses.append(resp)
return responses
return people
@ -104,7 +164,34 @@ async def get_person(
if not person:
raise HTTPException(status_code=404, detail="Person not found")
return person
resp = PersonResponse.model_validate(person)
if person.linked_user_id:
linked_user_result = await db.execute(
select(User).where(User.id == person.linked_user_id)
)
linked_user = linked_user_result.scalar_one_or_none()
linked_settings_result = await db.execute(
select(Settings).where(Settings.user_id == person.linked_user_id)
)
linked_settings = linked_settings_result.scalar_one_or_none()
conn_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == current_user.id,
UserConnection.connected_user_id == person.linked_user_id,
)
)
conn = conn_result.scalar_one_or_none()
if linked_user and linked_settings:
resp.shared_fields = resolve_shared_profile(
linked_user, linked_settings, conn.sharing_overrides if conn else None
)
resp.shared_fields["umbral_name"] = linked_user.umbral_name
# Show the latest update time across local record and connected user's profile
if linked_user.updated_at and linked_settings.updated_at:
remote_updated = max(linked_user.updated_at, linked_settings.updated_at)
if remote_updated > person.updated_at:
resp.updated_at = remote_updated
return resp
@router.put("/{person_id}", response_model=PersonResponse)
@ -144,13 +231,79 @@ async def update_person(
return person
async def _sever_connection(db: AsyncSession, current_user: User, person: Person) -> None:
"""Remove bidirectional UserConnection rows and detach the counterpart's Person."""
if not person.linked_user_id:
return
counterpart_id = person.linked_user_id
# Find our connection
conn_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == current_user.id,
UserConnection.connected_user_id == counterpart_id,
)
)
our_conn = conn_result.scalar_one_or_none()
# Find reverse connection
reverse_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == counterpart_id,
UserConnection.connected_user_id == current_user.id,
)
)
reverse_conn = reverse_result.scalar_one_or_none()
# Detach the counterpart's Person record (if it exists)
if reverse_conn and reverse_conn.person_id:
cp_result = await db.execute(
select(Person).where(Person.id == reverse_conn.person_id)
)
cp_person = cp_result.scalar_one_or_none()
if cp_person:
await detach_umbral_contact(cp_person)
# Delete both connection rows
if our_conn:
await db.delete(our_conn)
if reverse_conn:
await db.delete(reverse_conn)
@router.put("/{person_id}/unlink", response_model=PersonResponse)
async def unlink_person(
person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Unlink an umbral contact — convert to standard contact and sever the connection."""
result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
person = result.scalar_one_or_none()
if not person:
raise HTTPException(status_code=404, detail="Person not found")
if not person.is_umbral_contact:
raise HTTPException(status_code=400, detail="Person is not an umbral contact")
await _sever_connection(db, current_user, person)
await detach_umbral_contact(person)
await db.commit()
await db.refresh(person)
return person
@router.delete("/{person_id}", status_code=204)
async def delete_person(
person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a person."""
"""Delete a person. If umbral contact, also severs the bidirectional connection."""
result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
@ -159,6 +312,9 @@ async def delete_person(
if not person:
raise HTTPException(status_code=404, detail="Person not found")
if person.is_umbral_contact:
await _sever_connection(db, current_user, person)
await db.delete(person)
await db.commit()

View File

@ -39,6 +39,27 @@ def _to_settings_response(s: Settings) -> SettingsResponse:
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
auto_lock_enabled=s.auto_lock_enabled,
auto_lock_minutes=s.auto_lock_minutes,
# Profile fields
phone=s.phone,
mobile=s.mobile,
address=s.address,
company=s.company,
job_title=s.job_title,
# Social settings
accept_connections=s.accept_connections,
# Sharing defaults
share_first_name=s.share_first_name,
share_last_name=s.share_last_name,
share_preferred_name=s.share_preferred_name,
share_email=s.share_email,
share_phone=s.share_phone,
share_mobile=s.share_mobile,
share_birthday=s.share_birthday,
share_address=s.share_address,
share_company=s.share_company,
share_job_title=s.share_job_title,
# ntfy connections toggle
ntfy_connections_enabled=s.ntfy_connections_enabled,
created_at=s.created_at,
updated_at=s.updated_at,
)

View File

@ -20,6 +20,7 @@ from app.schemas.auth import _validate_username, _validate_password_strength, _v
class UserListItem(BaseModel):
id: int
username: str
umbral_name: str = ""
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None

View File

@ -172,6 +172,19 @@ class ProfileUpdate(BaseModel):
last_name: str | None = Field(None, max_length=100)
email: str | None = Field(None, max_length=254)
date_of_birth: date | None = None
umbral_name: str | None = Field(None, min_length=3, max_length=50)
@field_validator("umbral_name")
@classmethod
def validate_umbral_name(cls, v: str | None) -> str | None:
if v is None:
return v
import re
if ' ' in v:
raise ValueError('Umbral name must be a single word with no spaces')
if not re.match(r'^[a-zA-Z0-9_.-]{3,50}$', v):
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
return v
@field_validator("email")
@classmethod
@ -199,6 +212,7 @@ class ProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
username: str
umbral_name: str
email: str | None
first_name: str | None
last_name: str | None

View File

@ -0,0 +1,91 @@
"""
Connection schemas search, request, respond, connection management.
All input schemas use extra="forbid" to prevent mass-assignment.
"""
import re
from typing import Literal, Optional
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
_UMBRAL_NAME_RE = re.compile(r'^[a-zA-Z0-9_.-]{3,50}$')
class UmbralSearchRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
umbral_name: str = Field(..., max_length=50)
@field_validator('umbral_name')
@classmethod
def validate_umbral_name(cls, v: str) -> str:
if not _UMBRAL_NAME_RE.match(v):
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
return v
class UmbralSearchResponse(BaseModel):
found: bool
class SendConnectionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
umbral_name: str = Field(..., max_length=50)
person_id: Optional[int] = Field(default=None, ge=1, le=2147483647)
@field_validator('umbral_name')
@classmethod
def validate_umbral_name(cls, v: str) -> str:
if not _UMBRAL_NAME_RE.match(v):
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
return v
class ConnectionRequestResponse(BaseModel):
id: int
sender_umbral_name: str
sender_preferred_name: Optional[str] = None
receiver_umbral_name: str
receiver_preferred_name: Optional[str] = None
status: Literal["pending", "accepted", "rejected", "cancelled"]
created_at: datetime
class RespondRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["accept", "reject"]
class ConnectionResponse(BaseModel):
id: int
connected_user_id: int
connected_umbral_name: str
connected_preferred_name: Optional[str] = None
person_id: Optional[int] = None
created_at: datetime
class RespondAcceptResponse(BaseModel):
message: str
connection_id: int
class RespondRejectResponse(BaseModel):
message: str
class CancelResponse(BaseModel):
message: str
class SharingOverrideUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
first_name: Optional[bool] = None
last_name: Optional[bool] = None
preferred_name: Optional[bool] = None
email: Optional[bool] = None
phone: Optional[bool] = None
mobile: Optional[bool] = None
birthday: Optional[bool] = None
address: Optional[bool] = None
company: Optional[bool] = None
job_title: Optional[bool] = None

View File

@ -0,0 +1,38 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from datetime import datetime
from typing import Optional
class NotificationResponse(BaseModel):
id: int
user_id: int
type: str
title: Optional[str] = None
message: Optional[str] = None
data: Optional[dict] = None
source_type: Optional[str] = None
source_id: Optional[int] = None
is_read: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class NotificationListResponse(BaseModel):
notifications: list[NotificationResponse]
unread_count: int
total: int
class MarkReadRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
notification_ids: list[int] = Field(..., min_length=1, max_length=100, json_schema_extra={"items": {"minimum": 1, "maximum": 2147483647}})
@field_validator('notification_ids')
@classmethod
def validate_ids(cls, v: list[int]) -> list[int]:
for i in v:
if i < 1 or i > 2147483647:
raise ValueError('Each notification ID must be between 1 and 2147483647')
return v

View File

@ -85,6 +85,9 @@ class PersonResponse(BaseModel):
company: Optional[str]
job_title: Optional[str]
notes: Optional[str]
linked_user_id: Optional[int] = None
is_umbral_contact: bool = False
shared_fields: Optional[dict] = None
created_at: datetime
updated_at: datetime

View File

@ -37,6 +37,31 @@ class SettingsUpdate(BaseModel):
auto_lock_enabled: Optional[bool] = None
auto_lock_minutes: Optional[int] = None
# Profile fields (shareable with connections)
phone: Optional[str] = Field(None, max_length=50)
mobile: Optional[str] = Field(None, max_length=50)
address: Optional[str] = Field(None, max_length=2000)
company: Optional[str] = Field(None, max_length=255)
job_title: Optional[str] = Field(None, max_length=255)
# Social settings
accept_connections: Optional[bool] = None
# Sharing defaults
share_first_name: Optional[bool] = None
share_last_name: Optional[bool] = None
share_preferred_name: Optional[bool] = None
share_email: Optional[bool] = None
share_phone: Optional[bool] = None
share_mobile: Optional[bool] = None
share_birthday: Optional[bool] = None
share_address: Optional[bool] = None
share_company: Optional[bool] = None
share_job_title: Optional[bool] = None
# ntfy connections toggle
ntfy_connections_enabled: Optional[bool] = None
@field_validator('auto_lock_minutes')
@classmethod
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
@ -151,6 +176,31 @@ class SettingsResponse(BaseModel):
auto_lock_enabled: bool = False
auto_lock_minutes: int = 5
# Profile fields
phone: Optional[str] = None
mobile: Optional[str] = None
address: Optional[str] = None
company: Optional[str] = None
job_title: Optional[str] = None
# Social settings
accept_connections: bool = False
# Sharing defaults
share_first_name: bool = False
share_last_name: bool = False
share_preferred_name: bool = True
share_email: bool = False
share_phone: bool = False
share_mobile: bool = False
share_birthday: bool = False
share_address: bool = False
share_company: bool = False
share_job_title: bool = False
# ntfy connections toggle
ntfy_connections_enabled: bool = True
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,208 @@
"""
Connection service shared profile resolution, Person creation, ntfy dispatch.
SHAREABLE_FIELDS is the single source of truth for which fields can be shared.
"""
import asyncio
import logging
from datetime import date as date_type
from types import SimpleNamespace
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Person
from app.models.settings import Settings
from app.models.user import User
from app.services.ntfy import send_ntfy_notification
logger = logging.getLogger(__name__)
# Notification type constants — keep in sync with notifications model CHECK constraint
NOTIF_TYPE_CONNECTION_REQUEST = "connection_request"
NOTIF_TYPE_CONNECTION_ACCEPTED = "connection_accepted"
# Single source of truth — only these fields can be shared via connections
SHAREABLE_FIELDS = frozenset({
"first_name", "last_name", "preferred_name", "email", "phone", "mobile",
"birthday", "address", "company", "job_title",
})
# Maps shareable field names to their Settings model column names
_SETTINGS_FIELD_MAP = {
"first_name": None, # first_name comes from User model
"last_name": None, # last_name comes from User model
"preferred_name": "preferred_name",
"email": None, # email comes from User model
"phone": "phone",
"mobile": "mobile",
"birthday": None, # birthday comes from User model (date_of_birth)
"address": "address",
"company": "company",
"job_title": "job_title",
}
def resolve_shared_profile(
user: User,
settings: Settings,
overrides: Optional[dict] = None,
) -> dict:
"""
Merge global sharing defaults with per-connection overrides.
Returns {field: value} dict of fields the user is sharing.
Only fields in SHAREABLE_FIELDS are included.
"""
overrides = overrides or {}
result = {}
for field in SHAREABLE_FIELDS:
# Determine if this field is shared: override wins, else global default
share_key = f"share_{field}"
global_share = getattr(settings, share_key, False)
is_shared = overrides.get(field, global_share)
if not is_shared:
continue
# Resolve the actual value
if field == "first_name":
result[field] = user.first_name
elif field == "last_name":
result[field] = user.last_name
elif field == "preferred_name":
result[field] = settings.preferred_name
elif field == "email":
result[field] = user.email
elif field == "birthday":
result[field] = str(user.date_of_birth) if user.date_of_birth else None
elif field in _SETTINGS_FIELD_MAP and _SETTINGS_FIELD_MAP[field]:
result[field] = getattr(settings, _SETTINGS_FIELD_MAP[field], None)
return filter_to_shareable(result)
def filter_to_shareable(profile: dict) -> dict:
"""Strip any keys not in SHAREABLE_FIELDS. Defence-in-depth gate."""
return {k: v for k, v in profile.items() if k in SHAREABLE_FIELDS}
def create_person_from_connection(
owner_user_id: int,
connected_user: User,
connected_settings: Settings,
shared_profile: dict,
) -> Person:
"""Create a Person record for a new connection. Does NOT add to session — caller does."""
# Use shared first_name, fall back to preferred_name, then umbral_name
first_name = shared_profile.get("first_name") or shared_profile.get("preferred_name") or connected_user.umbral_name
last_name = shared_profile.get("last_name")
email = shared_profile.get("email")
phone = shared_profile.get("phone")
mobile = shared_profile.get("mobile")
address = shared_profile.get("address")
company = shared_profile.get("company")
job_title = shared_profile.get("job_title")
birthday_str = shared_profile.get("birthday")
birthday = None
if birthday_str:
try:
birthday = date_type.fromisoformat(birthday_str)
except (ValueError, TypeError):
pass
# Compute display name
full = ((first_name or '') + ' ' + (last_name or '')).strip()
display_name = full or connected_user.umbral_name
return Person(
user_id=owner_user_id,
name=display_name,
first_name=first_name,
last_name=last_name,
email=email,
phone=phone,
mobile=mobile,
address=address,
company=company,
job_title=job_title,
birthday=birthday,
category="Umbral",
linked_user_id=connected_user.id,
is_umbral_contact=True,
)
async def detach_umbral_contact(person: Person) -> None:
"""Convert an umbral contact back to a standard contact. Does NOT commit.
Preserves all person data (name, email, phone, etc.) so the user does not
lose contact information when a connection is severed. Only unlinks the
umbral association the person becomes a standard contact.
"""
person.linked_user_id = None
person.is_umbral_contact = False
person.category = None
def extract_ntfy_config(settings: Settings) -> dict | None:
"""Extract ntfy config values into a plain dict safe for use after session close."""
if not settings.ntfy_enabled or not settings.ntfy_connections_enabled:
return None
return {
"ntfy_enabled": True,
"ntfy_server_url": settings.ntfy_server_url,
"ntfy_topic": settings.ntfy_topic,
"ntfy_auth_token": settings.ntfy_auth_token,
"user_id": settings.user_id,
}
async def send_connection_ntfy(
ntfy_config: dict | None,
sender_name: str,
event_type: str,
) -> None:
"""Send ntfy push for connection events. Non-blocking with 3s timeout.
Accepts a plain dict (from extract_ntfy_config) to avoid accessing
detached SQLAlchemy objects after session close.
"""
if not ntfy_config:
return
title_map = {
"request_received": "New Connection Request",
"request_accepted": "Connection Accepted",
}
message_map = {
"request_received": f"{sender_name} wants to connect with you on Umbra",
"request_accepted": f"{sender_name} accepted your connection request",
}
tag_map = {
"request_received": ["handshake"],
"request_accepted": ["white_check_mark"],
}
title = title_map.get(event_type, "Connection Update")
message = message_map.get(event_type, f"Connection update from {sender_name}")
tags = tag_map.get(event_type, ["bell"])
# Build a settings-like object for send_ntfy_notification (avoids detached SA objects)
settings_proxy = SimpleNamespace(**ntfy_config)
try:
await asyncio.wait_for(
send_ntfy_notification(
settings=settings_proxy,
title=title,
message=message,
tags=tags,
priority=3,
),
timeout=3.0,
)
except asyncio.TimeoutError:
logger.warning("ntfy connection push timed out for user_id=%s", ntfy_config["user_id"])
except Exception:
logger.warning("ntfy connection push failed for user_id=%s", ntfy_config["user_id"])

View File

@ -0,0 +1,34 @@
"""
In-app notification service.
Creates notification records for the notification centre.
Separate from ntfy push in-app notifications are always created;
ntfy push is gated by per-type toggles.
"""
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
async def create_notification(
db: AsyncSession,
user_id: int,
type: str,
title: str,
message: str,
data: Optional[dict] = None,
source_type: Optional[str] = None,
source_id: Optional[int] = None,
) -> Notification:
"""Create an in-app notification. Does NOT commit — caller handles transaction."""
notification = Notification(
user_id=user_id,
type=type,
title=title,
message=message,
data=data,
source_type=source_type,
source_id=source_id,
)
db.add(notification)
return notification

View File

@ -4,6 +4,9 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m;
# Admin API generous for legitimate use but still guards against scraping/brute-force
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
# Connection endpoints prevent search enumeration and request spam
limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
map $http_x_forwarded_proto $forwarded_proto {
@ -82,6 +85,20 @@ server {
include /etc/nginx/proxy-params.conf;
}
# Connection search rate-limited to prevent user enumeration
location /api/connections/search {
limit_req zone=conn_search_limit burst=5 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Connection request (send) exact match to avoid catching /requests/*
location = /api/connections/request {
limit_req zone=conn_request_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Admin API rate-limited separately from general /api traffic
location /api/admin/ {
limit_req zone=admin_limit burst=10 nodelay;

View File

@ -12,6 +12,7 @@ import ProjectDetail from '@/components/projects/ProjectDetail';
import PeoplePage from '@/components/people/PeoplePage';
import LocationsPage from '@/components/locations/LocationsPage';
import SettingsPage from '@/components/settings/SettingsPage';
import NotificationsPage from '@/components/notifications/NotificationsPage';
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
@ -72,6 +73,7 @@ function App() {
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="people" element={<PeoplePage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route
path="admin/*"

View File

@ -30,6 +30,11 @@ const ACTION_TYPES = [
'auth.setup_complete',
'auth.registration',
'auth.mfa_enforce_prompted',
'connection.request_sent',
'connection.request_cancelled',
'connection.accepted',
'connection.rejected',
'connection.removed',
];
function actionLabel(action: string): string {
@ -44,7 +49,7 @@ export default function ConfigPage() {
const [filterAction, setFilterAction] = useState<string>('');
const PER_PAGE = 25;
const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined);
const { data, isLoading, error } = useAuditLog(page, PER_PAGE, filterAction || undefined);
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
@ -111,6 +116,11 @@ export default function ConfigPage() {
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : error ? (
<div className="px-5 pb-5">
<p className="text-sm text-destructive">Failed to load audit log</p>
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
</div>
) : !data?.entries?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No audit entries found.</p>
) : (

View File

@ -167,6 +167,9 @@ export default function IAMPage() {
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Umbral Name
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Email
</th>
@ -209,6 +212,9 @@ export default function IAMPage() {
)}
>
<td className="px-5 py-3 font-medium">{user.username}</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{user.umbral_name || user.username}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{user.email || '—'}
</td>

View File

@ -55,7 +55,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
}
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
const { data: user, isLoading } = useAdminUserDetail(userId);
const { data: user, isLoading, error } = useAdminUserDetail(userId);
const updateRole = useUpdateRole();
const handleRoleChange = async (newRole: UserRole) => {
@ -89,6 +89,22 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
);
}
if (error) {
return (
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between">
<p className="text-sm text-destructive">Failed to load user details</p>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onClose}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
</CardContent>
</Card>
);
}
if (!user) return null;
return (

View File

@ -0,0 +1,124 @@
import { useState, useEffect } from 'react';
import { Check, X, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { formatDistanceToNow } from 'date-fns';
import { Button } from '@/components/ui/button';
import { useConnections } from '@/hooks/useConnections';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
import { cn } from '@/lib/utils';
import type { ConnectionRequest } from '@/types';
interface ConnectionRequestCardProps {
request: ConnectionRequest;
direction: 'incoming' | 'outgoing';
}
export default function ConnectionRequestCard({ request, direction }: ConnectionRequestCardProps) {
const { respond, cancelRequest, isCancelling } = useConnections();
const [isResponding, setIsResponding] = useState(false);
const [resolved, setResolved] = useState(false);
// Clean up invisible DOM element after fade-out transition
const [hidden, setHidden] = useState(false);
useEffect(() => {
if (!resolved) return;
const timer = setTimeout(() => setHidden(true), 300);
return () => clearTimeout(timer);
}, [resolved]);
if (hidden) return null;
const handleRespond = async (action: 'accept' | 'reject') => {
setIsResponding(true);
try {
await respond({ requestId: request.id, action });
setResolved(true);
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
} catch (err) {
// 409 means the request was already resolved (e.g. accepted via toast or notification center)
if (axios.isAxiosError(err) && err.response?.status === 409) {
setResolved(true);
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
} finally {
setIsResponding(false);
}
};
const handleCancel = async () => {
try {
await cancelRequest(request.id);
setResolved(true);
toast.success('Request cancelled');
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to cancel request'));
}
};
const isIncoming = direction === 'incoming';
const displayName = isIncoming
? request.sender_preferred_name || request.sender_umbral_name
: request.receiver_preferred_name || request.receiver_umbral_name;
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-border p-3 transition-all duration-300',
resolved && 'opacity-0 translate-y-2'
)}
>
{/* Avatar */}
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-sm font-medium text-violet-400">
{displayName.charAt(0).toUpperCase()}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<p className="text-xs text-muted-foreground">
{isIncoming ? 'wants to connect' : 'request pending'} · {formatDistanceToNow(new Date(request.created_at), { addSuffix: true })}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1.5 shrink-0">
{isIncoming ? (
<>
<Button
size="sm"
onClick={() => handleRespond('accept')}
disabled={isResponding}
className="gap-1"
>
{isResponding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Accept
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRespond('reject')}
disabled={isResponding}
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={isCancelling}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
{isCancelling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,178 @@
import { useState } from 'react';
import { Search, UserPlus, Loader2, AlertCircle, CheckCircle, Settings } from 'lucide-react';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useConnections } from '@/hooks/useConnections';
import { useSettings } from '@/hooks/useSettings';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
interface ConnectionSearchProps {
open: boolean;
onOpenChange: (open: boolean) => void;
personId?: number;
}
export default function ConnectionSearch({ open, onOpenChange, personId }: ConnectionSearchProps) {
const { search, isSearching, sendRequest, isSending } = useConnections();
const { settings, isLoading: isLoadingSettings } = useSettings();
const navigate = useNavigate();
const [umbralName, setUmbralName] = useState('');
const [found, setFound] = useState<boolean | null>(null);
const [sent, setSent] = useState(false);
const acceptConnectionsEnabled = settings?.accept_connections ?? false;
const handleSearch = async () => {
if (!umbralName.trim()) return;
setFound(null);
setSent(false);
try {
const result = await search(umbralName.trim());
setFound(result.found);
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 429) {
toast.error('Too many searches — please wait a moment and try again');
} else {
setFound(false);
}
}
};
const handleSend = async () => {
try {
await sendRequest({ umbralName: umbralName.trim(), personId });
setSent(true);
toast.success('Connection request sent');
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to send request'));
}
};
const handleClose = () => {
setUmbralName('');
setFound(null);
setSent(false);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-violet-400" />
Find Umbra User
</DialogTitle>
<DialogDescription>
{personId
? 'Search for an umbral user to link this contact to.'
: 'Search for a user by their umbral name to send a connection request.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{isLoadingSettings ? (
<div className="flex justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
) : !acceptConnectionsEnabled ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<AlertCircle className="h-8 w-8 text-amber-400" />
<p className="text-sm text-muted-foreground">
You need to enable <span className="text-foreground font-medium">Accept Connections</span> in your settings before you can send or receive connection requests.
</p>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => { handleClose(); navigate('/settings'); }}
>
<Settings className="h-3.5 w-3.5" />
Go to Settings
</Button>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="umbral_search">Umbral Name</Label>
<div className="flex gap-2">
<Input
id="umbral_search"
placeholder="Enter umbral name..."
value={umbralName}
onChange={(e) => {
setUmbralName(e.target.value);
setFound(null);
setSent(false);
}}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
maxLength={50}
/>
<Button
onClick={handleSearch}
disabled={!umbralName.trim() || isSearching}
size="sm"
>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</div>
</div>
{found === false && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
User not found
</div>
)}
{found === true && !sent && (
<div className="flex items-center justify-between rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center">
<span className="text-sm font-medium text-violet-400">
{umbralName.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-sm font-medium">{umbralName}</span>
</div>
<Button
onClick={handleSend}
disabled={isSending}
size="sm"
className="gap-1.5"
>
{isSending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
Send Request
</Button>
</div>
)}
{sent && (
<div className="flex items-center gap-2 text-sm text-green-400">
<CheckCircle className="h-4 w-4" />
Connection request sent
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -4,9 +4,11 @@ import { Menu } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
import { AlertsProvider } from '@/hooks/useAlerts';
import { LockProvider } from '@/hooks/useLock';
import { NotificationProvider } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button';
import Sidebar from './Sidebar';
import LockOverlay from './LockOverlay';
import NotificationToaster from '@/components/notifications/NotificationToaster';
export default function AppLayout() {
useTheme();
@ -19,31 +21,34 @@ export default function AppLayout() {
return (
<LockProvider>
<AlertsProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile header */}
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
<NotificationProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile header */}
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
</div>
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
<LockOverlay />
<LockOverlay />
<NotificationToaster />
</NotificationProvider>
</AlertsProvider>
</LockProvider>
);

View File

@ -22,6 +22,7 @@ import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { useLock } from '@/hooks/useLock';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useNotifications } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button';
import api from '@/lib/api';
import type { Project } from '@/types';
@ -47,6 +48,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
const location = useLocation();
const { logout, isAdmin } = useAuth();
const { lock } = useLock();
const { unreadCount } = useNotifications();
const [projectsExpanded, setProjectsExpanded] = useState(false);
const { data: trackedProjects } = useQuery({
@ -194,6 +196,28 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
<Lock className="h-5 w-5 shrink-0" />
{showExpanded && <span>Lock</span>}
</button>
<NavLink
to="/notifications"
onClick={mobileOpen ? onMobileClose : undefined}
className={navLinkClass}
>
<div className="relative shrink-0">
<Bell className="h-5 w-5" />
{unreadCount > 0 && !showExpanded && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-red-500" />
)}
</div>
{showExpanded && (
<span className="flex items-center gap-2">
Notifications
{unreadCount > 0 && (
<span className="text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
{unreadCount}
</span>
)}
</span>
)}
</NavLink>
{isAdmin && (
<NavLink
to="/admin"

View File

@ -0,0 +1,149 @@
import { useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import { Check, X, Bell, UserPlus } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
import type { AppNotification } from '@/types';
export default function NotificationToaster() {
const { notifications, unreadCount, markRead } = useNotifications();
const { respond } = useConnections();
const queryClient = useQueryClient();
const maxSeenIdRef = useRef(0);
const initializedRef = useRef(false);
const prevUnreadRef = useRef(0);
// Track in-flight request IDs so repeated clicks are blocked
const respondingRef = useRef<Set<number>>(new Set());
// Always call the latest respond — Sonner toasts capture closures at creation time
const respondRef = useRef(respond);
respondRef.current = respond;
const markReadRef = useRef(markRead);
markReadRef.current = markRead;
const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
if (respondingRef.current.has(requestId)) return;
respondingRef.current.add(requestId);
// Immediately dismiss the custom toast and show a loading indicator
toast.dismiss(toastId);
const loadingId = toast.loading(
action === 'accept' ? 'Accepting connection…' : 'Declining request…',
);
try {
await respondRef.current({ requestId, action });
toast.dismiss(loadingId);
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
markReadRef.current([notificationId]).catch(() => {});
} catch (err) {
toast.dismiss(loadingId);
// 409 means the request was already resolved (e.g. accepted via notification center)
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
markReadRef.current([notificationId]).catch(() => {});
} else {
toast.error(getErrorMessage(err, 'Failed to respond to request'));
}
} finally {
respondingRef.current.delete(requestId);
}
},
[],
);
// Track unread count changes to force-refetch the list
useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] });
}
prevUnreadRef.current = unreadCount;
}, [unreadCount, queryClient]);
// Show toasts for new notifications (ID > max seen)
useEffect(() => {
if (!notifications.length) return;
// On first load, record the max ID without toasting
if (!initializedRef.current) {
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
initializedRef.current = true;
return;
}
// Find unread notifications with IDs higher than our watermark
const newNotifications = notifications.filter(
(n) => !n.is_read && n.id > maxSeenIdRef.current,
);
// Advance watermark
const maxCurrent = Math.max(...notifications.map((n) => n.id));
if (maxCurrent > maxSeenIdRef.current) {
maxSeenIdRef.current = maxCurrent;
}
// Eagerly refresh incoming requests when connection_request notifications arrive
// so accept buttons work immediately on NotificationsPage / PeoplePage
if (newNotifications.some((n) => n.type === 'connection_request')) {
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
}
// Show toasts
newNotifications.forEach((notification) => {
if (notification.type === 'connection_request' && notification.source_id) {
showConnectionRequestToast(notification);
} else {
toast(notification.title || 'New Notification', {
description: notification.message || undefined,
icon: <Bell className="h-4 w-4" />,
duration: 8000,
});
}
});
}, [notifications, handleConnectionRespond]);
const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<UserPlus className="h-4 w-4 text-violet-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Connection Request</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'Someone wants to connect with you'}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => handleConnectionRespond(requestId, 'accept', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={() => handleConnectionRespond(requestId, 'reject', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Reject
</button>
</div>
</div>
</div>
</div>
),
{ id: `connection-request-${requestId}`, duration: 30000 },
);
};
return null;
}

View File

@ -0,0 +1,285 @@
import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
import { ListSkeleton } from '@/components/ui/skeleton';
import type { AppNotification } from '@/types';
const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
connection_request: { icon: UserPlus, color: 'text-violet-400' },
connection_accepted: { icon: UserPlus, color: 'text-green-400' },
info: { icon: Info, color: 'text-blue-400' },
warning: { icon: AlertCircle, color: 'text-amber-400' },
};
type Filter = 'all' | 'unread';
export default function NotificationsPage() {
const {
notifications,
unreadCount,
isLoading,
markRead,
markAllRead,
deleteNotification,
} = useNotifications();
const { incomingRequests, respond, isResponding } = useConnections();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>('all');
// Build a set of pending connection request IDs for quick lookup
const pendingRequestIds = useMemo(
() => new Set(incomingRequests.map((r) => r.id)),
[incomingRequests],
);
// Eagerly fetch incoming requests when notifications contain connection_request
// entries whose source_id isn't in pendingRequestIds yet (stale connections data)
useEffect(() => {
const hasMissing = notifications.some(
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id),
);
if (hasMissing) {
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
}
}, [notifications, pendingRequestIds, queryClient]);
const filtered = useMemo(() => {
if (filter === 'unread') return notifications.filter((n) => !n.is_read);
return notifications;
}, [notifications, filter]);
const handleMarkRead = async (id: number) => {
try {
await markRead([id]);
} catch { /* toast handled by mutation */ }
};
const handleDelete = async (id: number) => {
try {
await deleteNotification(id);
} catch { /* toast handled by mutation */ }
};
const handleMarkAllRead = async () => {
try {
await markAllRead();
} catch { /* toast handled by mutation */ }
};
const getIcon = (type: string) => {
const config = typeIcons[type] || { icon: Bell, color: 'text-muted-foreground' };
return config;
};
const handleConnectionRespond = async (
notification: AppNotification,
action: 'accept' | 'reject',
) => {
if (!notification.source_id) return;
try {
await respond({ requestId: notification.source_id, action });
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
} catch (err) {
// 409 means the request was already resolved (e.g. accepted via toast)
if (axios.isAxiosError(err) && err.response?.status === 409) {
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
}
};
const handleNotificationClick = async (notification: AppNotification) => {
// Don't navigate for pending connection requests — let user act inline
if (
notification.type === 'connection_request' &&
notification.source_id &&
pendingRequestIds.has(notification.source_id)
) {
return;
}
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
// Navigate to People for connection-related notifications
if (notification.type === 'connection_request' || notification.type === 'connection_accepted') {
navigate('/people');
}
};
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Page header */}
<div className="border-b bg-card px-6 h-16 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-accent" aria-hidden="true" />
<h1 className="text-xl font-semibold font-heading">Notifications</h1>
</div>
<div className="flex items-center gap-2">
{/* Filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
{(['all', 'unread'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'px-3 py-1.5 text-xs font-medium transition-colors capitalize',
filter === f
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)}
>
{f}
{f === 'unread' && unreadCount > 0 && (
<span className="ml-1.5 text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
{unreadCount}
</span>
)}
</button>
))}
</div>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleMarkAllRead}
className="text-xs gap-1.5"
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-6">
<ListSkeleton rows={5} />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3 py-20">
<Bell className="h-10 w-10 opacity-30" />
<p className="text-sm">
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
</p>
</div>
) : (
<div className="divide-y divide-border">
{filtered.map((notification) => {
const iconConfig = getIcon(notification.type);
const Icon = iconConfig.icon;
return (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={cn(
'flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer',
!notification.is_read && 'bg-card'
)}
>
{/* Type icon */}
<div className={cn('mt-0.5 shrink-0', iconConfig.color)}>
<Icon className="h-4 w-4" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm truncate',
!notification.is_read ? 'font-medium text-foreground' : 'text-muted-foreground'
)}>
{notification.title}
</p>
{notification.message && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{notification.message}
</p>
)}
</div>
{/* Unread dot */}
{!notification.is_read && (
<div className="h-2 w-2 rounded-full bg-accent shrink-0 mt-1.5" />
)}
</div>
</div>
{/* Connection request actions (inline) */}
{notification.type === 'connection_request' &&
notification.source_id &&
pendingRequestIds.has(notification.source_id) && (
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); handleConnectionRespond(notification, 'accept'); }}
disabled={isResponding}
className="gap-1 h-7 text-xs"
>
{isResponding ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Accept
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleConnectionRespond(notification, 'reject'); }}
disabled={isResponding}
className="h-7 text-xs"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* Timestamp + actions */}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!notification.is_read && (
<button
onClick={(e) => { e.stopPropagation(); handleMarkRead(notification.id); }}
className="p-1 rounded hover:bg-accent/10 text-muted-foreground hover:text-accent transition-colors"
title="Mark as read"
>
<Check className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); handleDelete(notification.id); }}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format, parseISO, differenceInYears } from 'date-fns';
@ -23,6 +23,9 @@ import {
import { useTableVisibility } from '@/hooks/useTableVisibility';
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
import PersonForm from './PersonForm';
import ConnectionSearch from '@/components/connections/ConnectionSearch';
import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard';
import { useConnections } from '@/hooks/useConnections';
// ---------------------------------------------------------------------------
// StatCounter — inline helper
@ -57,7 +60,11 @@ function StatCounter({
// Helpers
// ---------------------------------------------------------------------------
function getPersonInitialsName(p: Person): string {
const parts = [p.first_name, p.last_name].filter(Boolean);
const firstName = p.is_umbral_contact && p.shared_fields?.first_name
? String(p.shared_fields.first_name) : p.first_name;
const lastName = p.is_umbral_contact && p.shared_fields?.last_name
? String(p.shared_fields.last_name) : p.last_name;
const parts = [firstName, lastName].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : p.name;
}
@ -82,6 +89,14 @@ function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/** Get a field value, preferring shared_fields for umbral contacts. */
function sf(p: Person, key: string): string | null | undefined {
if (p.is_umbral_contact && p.shared_fields && key in p.shared_fields) {
return p.shared_fields[key] as string | null;
}
return p[key as keyof Person] as string | null | undefined;
}
const columns: ColumnDef<Person>[] = [
{
key: 'name',
@ -89,7 +104,10 @@ const columns: ColumnDef<Person>[] = [
sortable: true,
visibilityLevel: 'essential',
render: (p) => {
const initialsName = getPersonInitialsName(p);
const firstName = sf(p, 'first_name');
const lastName = sf(p, 'last_name');
const liveName = [firstName, lastName].filter(Boolean).join(' ') || p.nickname || p.name;
const initialsName = liveName || getPersonInitialsName(p);
return (
<div className="flex items-center gap-2.5">
<div
@ -97,7 +115,10 @@ const columns: ColumnDef<Person>[] = [
>
{getInitials(initialsName)}
</div>
<span className="font-medium truncate">{p.nickname || p.name}</span>
<span className="font-medium truncate">{liveName}</span>
{p.is_umbral_contact && (
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" aria-label="Umbral contact" />
)}
</div>
);
},
@ -107,18 +128,21 @@ const columns: ColumnDef<Person>[] = [
label: 'Number',
sortable: false,
visibilityLevel: 'essential',
render: (p) => (
<span className="text-muted-foreground truncate">{p.mobile || p.phone || '—'}</span>
),
render: (p) => {
const mobile = sf(p, 'mobile');
const phone = sf(p, 'phone');
return <span className="text-muted-foreground truncate">{mobile || phone || '—'}</span>;
},
},
{
key: 'email',
label: 'Email',
sortable: true,
visibilityLevel: 'essential',
render: (p) => (
<span className="text-muted-foreground truncate">{p.email || '—'}</span>
),
render: (p) => {
const email = sf(p, 'email');
return <span className="text-muted-foreground truncate">{email || '—'}</span>;
},
},
{
key: 'job_title',
@ -126,10 +150,10 @@ const columns: ColumnDef<Person>[] = [
sortable: true,
visibilityLevel: 'filtered',
render: (p) => {
const parts = [p.job_title, p.company].filter(Boolean);
return (
<span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>
);
const jobTitle = sf(p, 'job_title');
const company = sf(p, 'company');
const parts = [jobTitle, company].filter(Boolean);
return <span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>;
},
},
{
@ -137,12 +161,14 @@ const columns: ColumnDef<Person>[] = [
label: 'Birthday',
sortable: true,
visibilityLevel: 'filtered',
render: (p) =>
p.birthday ? (
<span className="text-muted-foreground">{format(parseISO(p.birthday), 'MMM d')}</span>
render: (p) => {
const birthday = sf(p, 'birthday');
return birthday ? (
<span className="text-muted-foreground">{format(parseISO(birthday), 'MMM d')}</span>
) : (
<span className="text-muted-foreground"></span>
),
);
},
},
{
key: 'category',
@ -170,6 +196,7 @@ const columns: ColumnDef<Person>[] = [
// Panel field config
// ---------------------------------------------------------------------------
const panelFields: PanelField[] = [
{ label: 'Preferred Name', key: 'preferred_name', icon: User2 },
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
@ -193,9 +220,17 @@ export default function PeoplePage() {
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [showPinned, setShowPinned] = useState(true);
const [showUmbralOnly, setShowUmbralOnly] = useState(false);
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<string>('name');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
const [linkPersonId, setLinkPersonId] = useState<number | null>(null);
const [showAddDropdown, setShowAddDropdown] = useState(false);
const addDropdownRef = useRef<HTMLDivElement>(null);
const { incomingRequests, outgoingRequests } = useConnections();
const hasRequests = incomingRequests.length > 0 || outgoingRequests.length > 0;
const { data: people = [], isLoading } = useQuery({
queryKey: ['people'],
@ -228,6 +263,10 @@ export default function PeoplePage() {
? people.filter((p) => !p.is_favourite)
: people;
if (showUmbralOnly) {
list = list.filter((p) => p.is_umbral_contact);
}
if (activeFilters.length > 0) {
list = list.filter((p) => p.category && activeFilters.includes(p.category));
}
@ -249,7 +288,7 @@ export default function PeoplePage() {
}
return sortPeople(list, sortKey, sortDir);
}, [people, showPinned, activeFilters, search, sortKey, sortDir]);
}, [people, showPinned, showUmbralOnly, activeFilters, search, sortKey, sortDir]);
// Build row groups for the table — ordered by custom category order
const groups = useMemo(() => {
@ -314,6 +353,7 @@ export default function PeoplePage() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Person deleted');
@ -324,6 +364,22 @@ export default function PeoplePage() {
},
});
// Unlink umbral contact mutation
const unlinkMutation = useMutation({
mutationFn: async (personId: number) => {
const { data } = await api.put(`/people/${personId}/unlink`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['connections'] });
toast.success('Contact unlinked — converted to standard contact');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to unlink contact'));
},
});
// Toggle favourite mutation
const toggleFavouriteMutation = useMutation({
mutationFn: async (person: Person) => {
@ -347,6 +403,18 @@ export default function PeoplePage() {
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
// Close add dropdown on outside click
useEffect(() => {
if (!showAddDropdown) return;
const handler = (e: MouseEvent) => {
if (addDropdownRef.current && !addDropdownRef.current.contains(e.target as Node)) {
setShowAddDropdown(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showAddDropdown]);
const handleCloseForm = () => {
setShowForm(false);
setEditingPerson(null);
@ -363,17 +431,75 @@ export default function PeoplePage() {
{getInitials(initialsName)}
</div>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
<div className="flex items-center gap-2">
<h3 className="font-heading text-lg font-semibold truncate">{
p.is_umbral_contact && p.shared_fields
? [sf(p, 'first_name'), sf(p, 'last_name')].filter(Boolean).join(' ') || p.name
: p.name
}</h3>
{p.is_umbral_contact && (
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
)}
</div>
<div className="flex items-center gap-2">
{p.is_umbral_contact && p.shared_fields?.umbral_name ? (
<span className="text-xs text-violet-400/80 font-normal">
@{String(p.shared_fields.umbral_name)}
</span>
) : null}
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
</div>
</div>
</div>
);
};
// Panel getValue
// Shared field key mapping (panel key -> shared_fields key)
const sharedKeyMap: Record<string, string> = {
preferred_name: 'preferred_name',
email: 'email',
phone: 'phone',
mobile: 'mobile',
birthday_display: 'birthday',
address: 'address',
company: 'company',
job_title: 'job_title',
};
// Build dynamic panel fields with synced labels for shared fields
const dynamicPanelFields = useMemo((): PanelField[] => {
if (!selectedPerson?.is_umbral_contact || !selectedPerson.shared_fields) return panelFields;
const shared = selectedPerson.shared_fields;
return panelFields.map((f) => {
const sharedKey = sharedKeyMap[f.key];
if (sharedKey && sharedKey in shared) {
return { ...f, label: `${f.label} (synced)` };
}
return f;
});
}, [selectedPerson]);
// Panel getValue — overlays shared fields from connected user
const getPanelValue = (p: Person, key: string): string | undefined => {
// Check shared fields first for umbral contacts
if (p.is_umbral_contact && p.shared_fields) {
const sharedKey = sharedKeyMap[key];
if (sharedKey && sharedKey in p.shared_fields) {
const sharedVal = p.shared_fields[sharedKey];
if (key === 'birthday_display' && sharedVal) {
const bd = String(sharedVal);
try {
const age = differenceInYears(new Date(), parseISO(bd));
return `${format(parseISO(bd), 'MMM d, yyyy')} (${age})`;
} catch {
return bd;
}
}
return sharedVal != null ? String(sharedVal) : undefined;
}
}
if (key === 'birthday_display' && p.birthday) {
const age = differenceInYears(new Date(), parseISO(p.birthday));
return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`;
@ -385,7 +511,7 @@ export default function PeoplePage() {
const renderPanel = () => (
<EntityDetailPanel<Person>
item={selectedPerson}
fields={panelFields}
fields={dynamicPanelFields}
onEdit={() => {
setEditingPerson(selectedPerson);
setShowForm(true);
@ -399,6 +525,30 @@ export default function PeoplePage() {
isFavourite={selectedPerson?.is_favourite}
onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)}
favouriteLabel="favourite"
extraActions={(p) =>
p.is_umbral_contact ? (
<Button
variant="ghost"
size="sm"
onClick={() => unlinkMutation.mutate(p.id)}
disabled={unlinkMutation.isPending}
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
>
<Unlink className="h-3 w-3" />
Unlink
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setLinkPersonId(p.id)}
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
>
<Link2 className="h-3 w-3" />
Link
</Button>
)
}
/>
);
@ -420,12 +570,53 @@ export default function PeoplePage() {
onReorderCategories={reorderCategories}
searchValue={search}
onSearchChange={setSearch}
extraPinnedFilters={[
{
label: 'Umbral',
isActive: showUmbralOnly,
onToggle: () => setShowUmbralOnly((p) => !p),
},
]}
/>
</div>
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
<Plus className="mr-2 h-4 w-4" />
Add Person
</Button>
<div className="relative" ref={addDropdownRef}>
<div className="flex">
<Button
onClick={() => setShowForm(true)}
size="sm"
aria-label="Add person"
className="rounded-r-none"
>
<Plus className="mr-2 h-4 w-4" />
Add Person
</Button>
<Button
size="sm"
onClick={() => setShowAddDropdown((p) => !p)}
aria-label="More add options"
className="rounded-l-none border-l border-background/20 px-1.5"
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</div>
{showAddDropdown && (
<div className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-card shadow-lg z-50 py-1">
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setShowAddDropdown(false); setShowForm(true); }}
>
Standard Contact
</button>
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors flex items-center gap-2"
onClick={() => { setShowAddDropdown(false); setShowConnectionSearch(true); }}
>
<Ghost className="h-3.5 w-3.5 text-violet-400" />
Umbra Contact
</button>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
@ -472,6 +663,40 @@ export default function PeoplePage() {
</div>
)}
{/* Pending requests */}
{hasRequests && (
<div className="px-6 pb-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
Pending Requests
</span>
<span className="text-[10px] tabular-nums bg-accent/15 text-accent px-1.5 py-0.5 rounded-full font-medium">
{incomingRequests.length + outgoingRequests.length}
</span>
</div>
<div className="space-y-2">
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">Incoming</p>
)}
{incomingRequests.slice(0, 5).map((req) => (
<ConnectionRequestCard key={req.id} request={req} direction="incoming" />
))}
{incomingRequests.length > 5 && (
<p className="text-xs text-muted-foreground">+{incomingRequests.length - 5} more</p>
)}
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mt-3">Outgoing</p>
)}
{outgoingRequests.slice(0, 5).map((req) => (
<ConnectionRequestCard key={req.id} request={req} direction="outgoing" />
))}
{outgoingRequests.length > 5 && (
<p className="text-xs text-muted-foreground">+{outgoingRequests.length - 5} more</p>
)}
</div>
</div>
)}
{/* Main content: table + panel */}
<div className="flex-1 overflow-hidden flex">
{/* Table */}
@ -558,6 +783,17 @@ export default function PeoplePage() {
onClose={handleCloseForm}
/>
)}
<ConnectionSearch
open={showConnectionSearch}
onOpenChange={setShowConnectionSearch}
/>
<ConnectionSearch
open={linkPersonId !== null}
onOpenChange={(open) => { if (!open) setLinkPersonId(null); }}
personId={linkPersonId ?? undefined}
/>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useMemo, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Star, StarOff, X } from 'lucide-react';
import { Star, StarOff, X, Lock } from 'lucide-react';
import { parseISO, differenceInYears } from 'date-fns';
import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types';
@ -30,6 +30,11 @@ interface PersonFormProps {
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
const queryClient = useQueryClient();
// Helper to resolve a field value — prefer shared_fields for umbral contacts
const sf = person?.shared_fields;
const shared = (key: string, fallback: string) =>
sf && key in sf && sf[key] != null ? String(sf[key]) : fallback;
const [formData, setFormData] = useState({
first_name:
person?.first_name ||
@ -38,20 +43,24 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
person?.last_name ||
(person?.name ? splitName(person.name).lastName : ''),
nickname: person?.nickname || '',
email: person?.email || '',
phone: person?.phone || '',
mobile: person?.mobile || '',
address: person?.address || '',
birthday: person?.birthday
? person.birthday.slice(0, 10)
: '',
email: shared('email', person?.email || ''),
phone: shared('phone', person?.phone || ''),
mobile: shared('mobile', person?.mobile || ''),
address: shared('address', person?.address || ''),
birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''),
category: person?.category || '',
is_favourite: person?.is_favourite ?? false,
company: person?.company || '',
job_title: person?.job_title || '',
company: shared('company', person?.company || ''),
job_title: shared('job_title', person?.job_title || ''),
notes: person?.notes || '',
});
// Check if a field is synced from an umbral connection (read-only)
const isShared = (fieldKey: string): boolean => {
if (!person?.is_umbral_contact || !person.shared_fields) return false;
return fieldKey in person.shared_fields;
};
const age = useMemo(() => {
if (!formData.birthday) return null;
try {
@ -165,13 +174,25 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
{/* Row 4: Birthday + Age */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label>
<Label htmlFor="birthday" className="flex items-center gap-1">
Birthday
{isShared('birthday') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
{isShared('birthday') ? (
<Input
id="birthday"
value={formData.birthday}
disabled
className="opacity-70 cursor-not-allowed"
/>
) : (
<DatePicker
variant="input"
id="birthday"
value={formData.birthday}
onChange={(v) => set('birthday', v)}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="age">Age</Label>
@ -200,65 +221,102 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
{/* Row 6: Mobile + Email */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mobile">Mobile</Label>
<Label htmlFor="mobile" className="flex items-center gap-1">
Mobile
{isShared('mobile') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="mobile"
type="tel"
value={formData.mobile}
onChange={(e) => set('mobile', e.target.value)}
disabled={isShared('mobile')}
className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email" className="flex items-center gap-1">
Email
{isShared('email') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => set('email', e.target.value)}
disabled={isShared('email')}
className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
</div>
{/* Row 7: Phone */}
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Label htmlFor="phone" className="flex items-center gap-1">
Phone
{isShared('phone') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => set('phone', e.target.value)}
placeholder="Landline / work number"
disabled={isShared('phone')}
className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
{/* Row 8: Address */}
<div className="space-y-2">
<Label htmlFor="address">Address</Label>
<LocationPicker
id="address"
value={formData.address}
onChange={(val) => set('address', val)}
onSelect={(result) => set('address', result.address || result.name)}
placeholder="Search or enter address..."
/>
<Label htmlFor="address" className="flex items-center gap-1">
Address
{isShared('address') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
{isShared('address') ? (
<Input
id="address"
value={formData.address}
disabled
className="opacity-70 cursor-not-allowed"
/>
) : (
<LocationPicker
id="address"
value={formData.address}
onChange={(val) => set('address', val)}
onSelect={(result) => set('address', result.address || result.name)}
placeholder="Search or enter address..."
/>
)}
</div>
{/* Row 9: Company + Job Title */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company">Company</Label>
<Label htmlFor="company" className="flex items-center gap-1">
Company
{isShared('company') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="company"
value={formData.company}
onChange={(e) => set('company', e.target.value)}
disabled={isShared('company')}
className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="job_title">Job Title</Label>
<Label htmlFor="job_title" className="flex items-center gap-1">
Job Title
{isShared('job_title') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="job_title"
value={formData.job_title}
onChange={(e) => set('job_title', e.target.value)}
disabled={isShared('job_title')}
className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
</div>

View File

@ -14,6 +14,7 @@ import {
Loader2,
Shield,
Blocks,
Ghost,
} from 'lucide-react';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@ -24,6 +25,7 @@ import { cn } from '@/lib/utils';
import api from '@/lib/api';
import type { GeoLocation, UserProfile } from '@/types';
import { Switch } from '@/components/ui/switch';
import CopyableField from '@/components/shared/CopyableField';
import TotpSetupSection from './TotpSetupSection';
import NtfySettingsSection from './NtfySettingsSection';
@ -55,6 +57,26 @@ export default function SettingsPage() {
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
// Profile extension fields (stored on Settings model)
const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? '');
const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? '');
const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? '');
const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? '');
const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? '');
// Social settings
const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false);
const [shareFirstName, setShareFirstName] = useState(settings?.share_first_name ?? false);
const [shareLastName, setShareLastName] = useState(settings?.share_last_name ?? false);
const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true);
const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false);
const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false);
const [shareMobile, setShareMobile] = useState(settings?.share_mobile ?? false);
const [shareBirthday, setShareBirthday] = useState(settings?.share_birthday ?? false);
const [shareAddress, setShareAddress] = useState(settings?.share_address ?? false);
const [shareCompany, setShareCompany] = useState(settings?.share_company ?? false);
const [shareJobTitle, setShareJobTitle] = useState(settings?.share_job_title ?? false);
// Profile fields (stored on User model, fetched from /auth/profile)
const profileQuery = useQuery({
queryKey: ['profile'],
@ -68,6 +90,8 @@ export default function SettingsPage() {
const [profileEmail, setProfileEmail] = useState('');
const [dateOfBirth, setDateOfBirth] = useState('');
const [emailError, setEmailError] = useState<string | null>(null);
const [umbralName, setUmbralName] = useState('');
const [umbralNameError, setUmbralNameError] = useState<string | null>(null);
useEffect(() => {
if (profileQuery.data) {
@ -75,6 +99,7 @@ export default function SettingsPage() {
setLastName(profileQuery.data.last_name ?? '');
setProfileEmail(profileQuery.data.email ?? '');
setDateOfBirth(profileQuery.data.date_of_birth ?? '');
setUmbralName(profileQuery.data.umbral_name ?? '');
}
}, [profileQuery.dataUpdatedAt]);
@ -87,6 +112,22 @@ export default function SettingsPage() {
setFirstDayOfWeek(settings.first_day_of_week);
setAutoLockEnabled(settings.auto_lock_enabled);
setAutoLockMinutes(settings.auto_lock_minutes ?? 5);
setSettingsPhone(settings.phone ?? '');
setSettingsMobile(settings.mobile ?? '');
setSettingsAddress(settings.address ?? '');
setSettingsCompany(settings.company ?? '');
setSettingsJobTitle(settings.job_title ?? '');
setAcceptConnections(settings.accept_connections);
setShareFirstName(settings.share_first_name);
setShareLastName(settings.share_last_name);
setSharePreferredName(settings.share_preferred_name);
setShareEmail(settings.share_email);
setSharePhone(settings.share_phone);
setShareMobile(settings.share_mobile);
setShareBirthday(settings.share_birthday);
setShareAddress(settings.share_address);
setShareCompany(settings.share_company);
setShareJobTitle(settings.share_job_title);
}
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
@ -173,8 +214,8 @@ export default function SettingsPage() {
}
};
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => {
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth };
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth' | 'umbral_name') => {
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth, umbral_name: umbralName };
const current = values[field].trim();
const original = profileQuery.data?.[field] ?? '';
if (current === (original || '')) return;
@ -188,6 +229,19 @@ export default function SettingsPage() {
}
setEmailError(null);
// Client-side umbral name validation
if (field === 'umbral_name') {
if (current.includes(' ')) {
setUmbralNameError('Must be a single word with no spaces');
return;
}
if (!current || !/^[a-zA-Z0-9_-]{3,50}$/.test(current)) {
setUmbralNameError('3-50 characters: letters, numbers, hyphens, underscores');
return;
}
setUmbralNameError(null);
}
try {
await api.put('/auth/profile', { [field]: current || null });
queryClient.invalidateQueries({ queryKey: ['profile'] });
@ -196,6 +250,8 @@ export default function SettingsPage() {
const detail = err?.response?.data?.detail;
if (field === 'email' && detail) {
setEmailError(typeof detail === 'string' ? detail : 'Failed to update email');
} else if (field === 'umbral_name' && detail) {
setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name');
} else {
toast.error(typeof detail === 'string' ? detail : 'Failed to update profile');
}
@ -248,6 +304,29 @@ export default function SettingsPage() {
}
};
const handleSettingsFieldSave = async (field: string, value: string) => {
const trimmed = value.trim();
const currentVal = (settings as any)?.[field] || '';
if (trimmed === (currentVal || '')) return;
try {
await updateSettings({ [field]: trimmed || null } as any);
toast.success('Profile updated');
} catch {
toast.error('Failed to update profile');
}
};
const handleSocialToggle = async (field: string, checked: boolean, setter: (v: boolean) => void) => {
const previous = (settings as any)?.[field];
setter(checked);
try {
await updateSettings({ [field]: checked } as any);
} catch {
setter(previous);
toast.error('Failed to update setting');
}
};
const handleAutoLockMinutesSave = async () => {
const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes;
const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw));
@ -363,6 +442,75 @@ export default function SettingsPage() {
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_phone">Phone</Label>
<Input
id="settings_phone"
type="tel"
placeholder="Phone number"
value={settingsPhone}
onChange={(e) => setSettingsPhone(e.target.value)}
onBlur={() => handleSettingsFieldSave('phone', settingsPhone)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_mobile">Mobile</Label>
<Input
id="settings_mobile"
type="tel"
placeholder="Mobile number"
value={settingsMobile}
onChange={(e) => setSettingsMobile(e.target.value)}
onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }}
maxLength={50}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="settings_address">Address</Label>
<Input
id="settings_address"
type="text"
placeholder="Your address"
value={settingsAddress}
onChange={(e) => setSettingsAddress(e.target.value)}
onBlur={() => handleSettingsFieldSave('address', settingsAddress)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }}
maxLength={2000}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_company">Company</Label>
<Input
id="settings_company"
type="text"
placeholder="Company name"
value={settingsCompany}
onChange={(e) => setSettingsCompany(e.target.value)}
onBlur={() => handleSettingsFieldSave('company', settingsCompany)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }}
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_job_title">Job Title</Label>
<Input
id="settings_job_title"
type="text"
placeholder="Your role"
value={settingsJobTitle}
onChange={(e) => setSettingsJobTitle(e.target.value)}
onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }}
maxLength={255}
/>
</div>
</div>
</CardContent>
</Card>
@ -586,9 +734,88 @@ export default function SettingsPage() {
</div>
{/* ── Right column: Security, Authentication, Integrations ── */}
{/* ── Right column: Social, Security, Authentication, Integrations ── */}
<div className="space-y-6">
{/* Social */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-violet-500/10">
<Ghost className="h-4 w-4 text-violet-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Social</CardTitle>
<CardDescription>Manage your Umbra identity and connections</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="umbral_name">Umbral Name</Label>
<div className="flex items-center gap-3">
<Input
id="umbral_name"
value={umbralName}
onChange={(e) => { setUmbralName(e.target.value); setUmbralNameError(null); }}
onBlur={() => handleProfileSave('umbral_name')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('umbral_name'); }}
maxLength={50}
placeholder="Your discoverable name"
className={umbralNameError ? 'border-red-500/50' : ''}
/>
<CopyableField value={umbralName} label="Umbral name" />
</div>
{umbralNameError ? (
<p className="text-xs text-red-400">{umbralNameError}</p>
) : (
<p className="text-sm text-muted-foreground">
How other Umbra users find you
</p>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Accept Connections</Label>
<p className="text-sm text-muted-foreground">
Allow other users to find and connect with you
</p>
</div>
<Switch
checked={acceptConnections}
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
/>
</div>
<div className="border-t border-border pt-4 mt-4">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
Sharing Defaults
</p>
<div className="grid grid-cols-2 gap-3">
{[
{ field: 'share_first_name', label: 'First Name', state: shareFirstName, setter: setShareFirstName },
{ field: 'share_last_name', label: 'Last Name', state: shareLastName, setter: setShareLastName },
{ field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName },
{ field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail },
{ field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone },
{ field: 'share_mobile', label: 'Mobile', state: shareMobile, setter: setShareMobile },
{ field: 'share_birthday', label: 'Birthday', state: shareBirthday, setter: setShareBirthday },
{ field: 'share_address', label: 'Address', state: shareAddress, setter: setShareAddress },
{ field: 'share_company', label: 'Company', state: shareCompany, setter: setShareCompany },
{ field: 'share_job_title', label: 'Job Title', state: shareJobTitle, setter: setShareJobTitle },
].map(({ field, label, state, setter }) => (
<div key={field} className="flex items-center justify-between">
<Label className="text-sm font-normal">{label}</Label>
<Switch
checked={state}
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
/>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Security (auto-lock) */}
<Card>
<CardHeader>

View File

@ -18,6 +18,12 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface ExtraPinnedFilter {
label: string;
isActive: boolean;
onToggle: () => void;
}
interface CategoryFilterBarProps {
activeFilters: string[];
pinnedLabel: string;
@ -30,6 +36,7 @@ interface CategoryFilterBarProps {
onReorderCategories?: (order: string[]) => void;
searchValue: string;
onSearchChange: (val: string) => void;
extraPinnedFilters?: ExtraPinnedFilter[];
}
const pillBase =
@ -116,6 +123,7 @@ export default function CategoryFilterBar({
onReorderCategories,
searchValue,
onSearchChange,
extraPinnedFilters = [],
}: CategoryFilterBarProps) {
const [otherOpen, setOtherOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
@ -169,6 +177,22 @@ export default function CategoryFilterBar({
</span>
</button>
{/* Extra pinned filters (e.g. "Umbral") */}
{extraPinnedFilters.map((epf) => (
<button
key={epf.label}
type="button"
onClick={epf.onToggle}
aria-label={`Filter by ${epf.label}`}
className={pillBase}
style={epf.isActive ? activePillStyle : undefined}
>
<span className={epf.isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
{epf.label}
</span>
</button>
))}
{/* Categories pill + expandable chips */}
{categories.length > 0 && (
<>

View File

@ -27,6 +27,7 @@ interface EntityDetailPanelProps<T> {
isFavourite?: boolean;
onToggleFavourite?: () => void;
favouriteLabel?: string;
extraActions?: (item: T) => React.ReactNode;
}
export function EntityDetailPanel<T>({
@ -42,6 +43,7 @@ export function EntityDetailPanel<T>({
isFavourite,
onToggleFavourite,
favouriteLabel = 'favourite',
extraActions,
}: EntityDetailPanelProps<T>) {
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
@ -134,7 +136,10 @@ export function EntityDetailPanel<T>({
{/* Footer */}
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
{extraActions?.(item)}
</div>
<div className="flex gap-2">
<Button
variant="outline"

View File

@ -0,0 +1,110 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api from '@/lib/api';
import type { Connection, ConnectionRequest, UmbralSearchResponse } from '@/types';
export function useConnections() {
const queryClient = useQueryClient();
const connectionsQuery = useQuery({
queryKey: ['connections'],
queryFn: async () => {
const { data } = await api.get<Connection[]>('/connections');
return data;
},
});
const incomingQuery = useQuery({
queryKey: ['connections', 'incoming'],
queryFn: async () => {
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/incoming');
return data;
},
refetchOnMount: 'always',
});
const outgoingQuery = useQuery({
queryKey: ['connections', 'outgoing'],
queryFn: async () => {
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/outgoing');
return data;
},
});
const searchMutation = useMutation({
mutationFn: async (umbralName: string) => {
const { data } = await api.post<UmbralSearchResponse>('/connections/search', {
umbral_name: umbralName,
});
return data;
},
});
const sendRequestMutation = useMutation({
mutationFn: async (params: { umbralName: string; personId?: number }) => {
const { data } = await api.post('/connections/request', {
umbral_name: params.umbralName,
...(params.personId != null && { person_id: params.personId }),
});
return data;
},
onSuccess: () => {
// Fire-and-forget — don't block mutateAsync on query refetches
queryClient.invalidateQueries({ queryKey: ['connections'] });
},
});
const respondMutation = useMutation({
mutationFn: async ({ requestId, action }: { requestId: number; action: 'accept' | 'reject' }) => {
const { data } = await api.put(`/connections/requests/${requestId}/respond`, { action });
return data;
},
onSuccess: (_, variables) => {
// Dismiss any lingering Sonner toast for this request
toast.dismiss(`connection-request-${variables.requestId}`);
// Fire-and-forget — errors here must not surface as mutation failures
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const cancelMutation = useMutation({
mutationFn: async (requestId: number) => {
const { data } = await api.put(`/connections/requests/${requestId}/cancel`);
return data;
},
onSuccess: () => {
// Fire-and-forget — don't block mutateAsync on query refetches
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const removeConnectionMutation = useMutation({
mutationFn: async (connectionId: number) => {
await api.delete(`/connections/${connectionId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['people'] });
},
});
return {
connections: connectionsQuery.data ?? [],
incomingRequests: incomingQuery.data ?? [],
outgoingRequests: outgoingQuery.data ?? [],
isLoading: connectionsQuery.isLoading,
isLoadingIncoming: incomingQuery.isLoading,
search: searchMutation.mutateAsync,
isSearching: searchMutation.isPending,
sendRequest: sendRequestMutation.mutateAsync,
isSending: sendRequestMutation.isPending,
respond: respondMutation.mutateAsync,
isResponding: respondMutation.isPending,
cancelRequest: cancelMutation.mutateAsync,
isCancelling: cancelMutation.isPending,
removeConnection: removeConnectionMutation.mutateAsync,
};
}

View File

@ -0,0 +1,113 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, createElement } from 'react';
import api from '@/lib/api';
import type { NotificationListResponse } from '@/types';
interface NotificationContextValue {
unreadCount: number;
notifications: NotificationListResponse['notifications'];
total: number;
isLoading: boolean;
markRead: (ids: number[]) => Promise<void>;
markAllRead: () => Promise<void>;
deleteNotification: (id: number) => Promise<void>;
refreshNotifications: () => void;
}
const NotificationContext = createContext<NotificationContextValue>({
unreadCount: 0,
notifications: [],
total: 0,
isLoading: true,
markRead: async () => {},
markAllRead: async () => {},
deleteNotification: async () => {},
refreshNotifications: () => {},
});
export function useNotifications() {
return useContext(NotificationContext);
}
export function NotificationProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const visibleRef = useRef(true);
useEffect(() => {
const handler = () => {
visibleRef.current = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
}, []);
const unreadQuery = useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: async () => {
const { data } = await api.get<{ count: number }>('/notifications/unread-count');
return data.count;
},
refetchInterval: 15_000,
// Required: toast notifications depend on background polling to detect new
// notifications when the tab is hidden (e.g. user switches to sender tab).
refetchIntervalInBackground: true,
staleTime: 10_000,
});
const listQuery = useQuery({
queryKey: ['notifications', 'list'],
queryFn: async () => {
const { data } = await api.get<NotificationListResponse>('/notifications', {
params: { per_page: 50 },
});
return data;
},
staleTime: 15_000,
refetchInterval: () => (visibleRef.current ? 15_000 : false),
});
const markReadMutation = useMutation({
mutationFn: async (notificationIds: number[]) => {
await api.put('/notifications/read', { notification_ids: notificationIds });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const markAllReadMutation = useMutation({
mutationFn: async () => {
await api.put('/notifications/read-all');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/notifications/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const refreshNotifications = () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
};
const value: NotificationContextValue = {
unreadCount: unreadQuery.data ?? 0,
notifications: listQuery.data?.notifications ?? [],
total: listQuery.data?.total ?? 0,
isLoading: listQuery.isLoading,
markRead: markReadMutation.mutateAsync,
markAllRead: markAllReadMutation.mutateAsync,
deleteNotification: deleteMutation.mutateAsync,
refreshNotifications,
};
return createElement(NotificationContext.Provider, { value }, children);
}

View File

@ -23,6 +23,27 @@ export interface Settings {
// Auto-lock settings
auto_lock_enabled: boolean;
auto_lock_minutes: number;
// Profile fields (shareable)
phone: string | null;
mobile: string | null;
address: string | null;
company: string | null;
job_title: string | null;
// Social settings
accept_connections: boolean;
// Sharing defaults
share_first_name: boolean;
share_last_name: boolean;
share_preferred_name: boolean;
share_email: boolean;
share_phone: boolean;
share_mobile: boolean;
share_birthday: boolean;
share_address: boolean;
share_company: boolean;
share_job_title: boolean;
// ntfy connections toggle
ntfy_connections_enabled: boolean;
created_at: string;
updated_at: string;
}
@ -171,6 +192,9 @@ export interface Person {
company?: string;
job_title?: string;
notes?: string;
linked_user_id?: number | null;
is_umbral_contact: boolean;
shared_fields?: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
@ -222,6 +246,7 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo
export interface AdminUser {
id: number;
username: string;
umbral_name: string;
email: string | null;
first_name: string | null;
last_name: string | null;
@ -348,6 +373,7 @@ export interface UpcomingResponse {
export interface UserProfile {
username: string;
umbral_name: string;
email: string | null;
first_name: string | null;
last_name: string | null;
@ -366,3 +392,50 @@ export interface EventTemplate {
is_starred: boolean;
created_at: string;
}
// ── Notifications ──────────────────────────────────────────────────
// Named AppNotification to avoid collision with browser Notification API
export interface AppNotification {
id: number;
user_id: number;
type: string;
title: string | null;
message: string | null;
data: Record<string, unknown> | null;
source_type: string | null;
source_id: number | null;
is_read: boolean;
created_at: string;
}
export interface NotificationListResponse {
notifications: AppNotification[];
unread_count: number;
total: number;
}
// ── Connections ────────────────────────────────────────────────────
export interface ConnectionRequest {
id: number;
sender_umbral_name: string;
sender_preferred_name: string | null;
receiver_umbral_name: string;
receiver_preferred_name: string | null;
status: 'pending' | 'accepted' | 'rejected' | 'cancelled';
created_at: string;
}
export interface Connection {
id: number;
connected_user_id: number;
connected_umbral_name: string;
connected_preferred_name: string | null;
person_id: number | null;
created_at: string;
}
export interface UmbralSearchResponse {
found: boolean;
}