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.database import AsyncSessionLocal
from app.models.settings import Settings from app.models.settings import Settings
from app.models.notification import Notification as AppNotification
from app.models.reminder import Reminder from app.models.reminder import Reminder
from app.models.calendar_event import CalendarEvent from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar 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.ntfy_sent import NtfySent
from app.models.totp_usage import TOTPUsage from app.models.totp_usage import TOTPUsage
from app.models.session import UserSession 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 import send_ntfy_notification
from app.services.ntfy_templates import ( from app.services.ntfy_templates import (
build_event_notification, build_event_notification,
@ -267,6 +269,37 @@ async def _purge_expired_sessions(db: AsyncSession) -> None:
await db.commit() 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 ─────────────────────────────────────────────────────────────── # ── Entry point ───────────────────────────────────────────────────────────────
async def run_notification_dispatch() -> None: async def run_notification_dispatch() -> None:
@ -308,6 +341,8 @@ async def run_notification_dispatch() -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
await _purge_totp_usage(db) await _purge_totp_usage(db)
await _purge_expired_sessions(db) await _purge_expired_sessions(db)
await _purge_old_notifications(db)
await _purge_resolved_requests(db)
except Exception: except Exception:
# Broad catch: job failure must never crash the scheduler or the app # 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.config import settings
from app.database import engine 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 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 from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them # 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 backup_code as _backup_code_model # noqa: F401
from app.models import system_config as _system_config_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 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(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) 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("/") @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.backup_code import BackupCode
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.models.audit_log import AuditLog 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__ = [ __all__ = [
"Settings", "Settings",
@ -34,4 +37,7 @@ __all__ = [
"BackupCode", "BackupCode",
"SystemConfig", "SystemConfig",
"AuditLog", "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) job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), 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()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=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 sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime from datetime import datetime
from typing import Optional 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_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5") 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 @property
def ntfy_has_token(self) -> bool: def ntfy_has_token(self) -> bool:
"""Derived field for SettingsResponse — True when an auth token is stored.""" """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) id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, 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) email: Mapped[str | None] = mapped_column(String(255), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_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, COALESCE: prefer the live username from the users table,
fall back to the username stored in the audit detail JSON fall back to the username stored in the audit detail JSON
(survives user deletion since audit_log.target_user_id SET NULL). (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( return sa.func.coalesce(
target_alias.username, target_alias.username,
sa.cast(audit_model.detail, JSONB)["username"].as_string(), json_fallback,
).label("target_username") ).label("target_username")
@ -170,9 +181,9 @@ async def get_user(
) )
active_sessions = session_result.scalar_one() active_sessions = session_result.scalar_one()
# Fetch preferred_name from Settings # Fetch preferred_name from Settings (limit 1 defensive)
settings_result = await db.execute( 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() preferred_name = settings_result.scalar_one_or_none()
@ -181,6 +192,8 @@ async def get_user(
active_sessions=active_sessions, active_sessions=active_sessions,
preferred_name=preferred_name, preferred_name=preferred_name,
date_of_birth=user.date_of_birth, 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( new_user = User(
username=data.username, username=data.username,
umbral_name=data.username,
password_hash=hash_password(data.password), password_hash=hash_password(data.password),
role=data.role, role=data.role,
email=email, email=email,
@ -241,6 +255,10 @@ async def create_user(
return UserDetailResponse( return UserDetailResponse(
**UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}), **UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}),
active_sessions=0, 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) password_hash = hash_password(data.password)
new_user = User( new_user = User(
username=data.username, username=data.username,
umbral_name=data.username,
password_hash=password_hash, password_hash=password_hash,
role="admin", role="admin",
last_password_change_at=datetime.now(), last_password_change_at=datetime.now(),
@ -440,7 +441,7 @@ async def register(
select(User).where(User.username == data.username) select(User).where(User.username == data.username)
) )
if existing.scalar_one_or_none(): 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) # Check email uniqueness (generic error to prevent enumeration)
if data.email: if data.email:
@ -454,6 +455,7 @@ async def register(
# SEC-01: Explicit field assignment — never **data.model_dump() # SEC-01: Explicit field assignment — never **data.model_dump()
new_user = User( new_user = User(
username=data.username, username=data.username,
umbral_name=data.username,
password_hash=password_hash, password_hash=password_hash,
role="standard", role="standard",
email=data.email, email=data.email,
@ -666,6 +668,15 @@ async def update_profile(
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email is already in use") 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 # SEC-01: Explicit field assignment — only allowed profile fields
if "first_name" in update_data: if "first_name" in update_data:
current_user.first_name = update_data["first_name"] current_user.first_name = update_data["first_name"]
@ -675,6 +686,8 @@ async def update_profile(
current_user.email = update_data["email"] current_user.email = update_data["email"]
if "date_of_birth" in update_data: if "date_of_birth" in update_data:
current_user.date_of_birth = update_data["date_of_birth"] 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( await log_audit_event(
db, action="auth.profile_updated", actor_id=current_user.id, 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 fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_ from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, List from typing import Optional, List
from app.database import get_db from app.database import get_db
from app.models.person import Person 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.schemas.person import PersonCreate, PersonUpdate, PersonResponse
from app.routers.auth import get_current_user 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() router = APIRouter()
@ -59,6 +63,62 @@ async def get_people(
result = await db.execute(query) result = await db.execute(query)
people = result.scalars().all() 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 return people
@ -104,7 +164,34 @@ async def get_person(
if not person: if not person:
raise HTTPException(status_code=404, detail="Person not found") 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) @router.put("/{person_id}", response_model=PersonResponse)
@ -144,13 +231,79 @@ async def update_person(
return 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) @router.delete("/{person_id}", status_code=204)
async def delete_person( async def delete_person(
person_id: int = Path(ge=1, le=2147483647), person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) 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( result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id) select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
) )
@ -159,6 +312,9 @@ async def delete_person(
if not person: if not person:
raise HTTPException(status_code=404, detail="Person not found") 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.delete(person)
await db.commit() 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 ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
auto_lock_enabled=s.auto_lock_enabled, auto_lock_enabled=s.auto_lock_enabled,
auto_lock_minutes=s.auto_lock_minutes, 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, created_at=s.created_at,
updated_at=s.updated_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): class UserListItem(BaseModel):
id: int id: int
username: str username: str
umbral_name: str = ""
email: Optional[str] = None email: Optional[str] = None
first_name: Optional[str] = None first_name: Optional[str] = None
last_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) last_name: str | None = Field(None, max_length=100)
email: str | None = Field(None, max_length=254) email: str | None = Field(None, max_length=254)
date_of_birth: date | None = None 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") @field_validator("email")
@classmethod @classmethod
@ -199,6 +212,7 @@ class ProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
username: str username: str
umbral_name: str
email: str | None email: str | None
first_name: str | None first_name: str | None
last_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] company: Optional[str]
job_title: Optional[str] job_title: Optional[str]
notes: Optional[str] notes: Optional[str]
linked_user_id: Optional[int] = None
is_umbral_contact: bool = False
shared_fields: Optional[dict] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@ -37,6 +37,31 @@ class SettingsUpdate(BaseModel):
auto_lock_enabled: Optional[bool] = None auto_lock_enabled: Optional[bool] = None
auto_lock_minutes: Optional[int] = 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') @field_validator('auto_lock_minutes')
@classmethod @classmethod
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]: 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_enabled: bool = False
auto_lock_minutes: int = 5 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 created_at: datetime
updated_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; 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 # 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; 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 # Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
map $http_x_forwarded_proto $forwarded_proto { map $http_x_forwarded_proto $forwarded_proto {
@ -82,6 +85,20 @@ server {
include /etc/nginx/proxy-params.conf; 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 # Admin API rate-limited separately from general /api traffic
location /api/admin/ { location /api/admin/ {
limit_req zone=admin_limit burst=10 nodelay; 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 PeoplePage from '@/components/people/PeoplePage';
import LocationsPage from '@/components/locations/LocationsPage'; import LocationsPage from '@/components/locations/LocationsPage';
import SettingsPage from '@/components/settings/SettingsPage'; import SettingsPage from '@/components/settings/SettingsPage';
import NotificationsPage from '@/components/notifications/NotificationsPage';
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal')); const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
@ -72,6 +73,7 @@ function App() {
<Route path="projects/:id" element={<ProjectDetail />} /> <Route path="projects/:id" element={<ProjectDetail />} />
<Route path="people" element={<PeoplePage />} /> <Route path="people" element={<PeoplePage />} />
<Route path="locations" element={<LocationsPage />} /> <Route path="locations" element={<LocationsPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route <Route
path="admin/*" path="admin/*"

View File

@ -30,6 +30,11 @@ const ACTION_TYPES = [
'auth.setup_complete', 'auth.setup_complete',
'auth.registration', 'auth.registration',
'auth.mfa_enforce_prompted', 'auth.mfa_enforce_prompted',
'connection.request_sent',
'connection.request_cancelled',
'connection.accepted',
'connection.rejected',
'connection.removed',
]; ];
function actionLabel(action: string): string { function actionLabel(action: string): string {
@ -44,7 +49,7 @@ export default function ConfigPage() {
const [filterAction, setFilterAction] = useState<string>(''); const [filterAction, setFilterAction] = useState<string>('');
const PER_PAGE = 25; 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; 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" /> <Skeleton key={i} className="h-10 w-full" />
))} ))}
</div> </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 ? ( ) : !data?.entries?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No audit entries found.</p> <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"> <th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username Username
</th> </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"> <th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Email Email
</th> </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 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"> <td className="px-5 py-3 text-muted-foreground text-xs">
{user.email || '—'} {user.email || '—'}
</td> </td>

View File

@ -55,7 +55,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
} }
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) { export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
const { data: user, isLoading } = useAdminUserDetail(userId); const { data: user, isLoading, error } = useAdminUserDetail(userId);
const updateRole = useUpdateRole(); const updateRole = useUpdateRole();
const handleRoleChange = async (newRole: UserRole) => { 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; if (!user) return null;
return ( 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 { useTheme } from '@/hooks/useTheme';
import { AlertsProvider } from '@/hooks/useAlerts'; import { AlertsProvider } from '@/hooks/useAlerts';
import { LockProvider } from '@/hooks/useLock'; import { LockProvider } from '@/hooks/useLock';
import { NotificationProvider } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import LockOverlay from './LockOverlay'; import LockOverlay from './LockOverlay';
import NotificationToaster from '@/components/notifications/NotificationToaster';
export default function AppLayout() { export default function AppLayout() {
useTheme(); useTheme();
@ -19,6 +21,7 @@ export default function AppLayout() {
return ( return (
<LockProvider> <LockProvider>
<AlertsProvider> <AlertsProvider>
<NotificationProvider>
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-screen overflow-hidden bg-background">
<Sidebar <Sidebar
collapsed={collapsed} collapsed={collapsed}
@ -44,6 +47,8 @@ export default function AppLayout() {
</div> </div>
</div> </div>
<LockOverlay /> <LockOverlay />
<NotificationToaster />
</NotificationProvider>
</AlertsProvider> </AlertsProvider>
</LockProvider> </LockProvider>
); );

View File

@ -22,6 +22,7 @@ import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useLock } from '@/hooks/useLock'; import { useLock } from '@/hooks/useLock';
import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useNotifications } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
@ -47,6 +48,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
const location = useLocation(); const location = useLocation();
const { logout, isAdmin } = useAuth(); const { logout, isAdmin } = useAuth();
const { lock } = useLock(); const { lock } = useLock();
const { unreadCount } = useNotifications();
const [projectsExpanded, setProjectsExpanded] = useState(false); const [projectsExpanded, setProjectsExpanded] = useState(false);
const { data: trackedProjects } = useQuery({ const { data: trackedProjects } = useQuery({
@ -194,6 +196,28 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
<Lock className="h-5 w-5 shrink-0" /> <Lock className="h-5 w-5 shrink-0" />
{showExpanded && <span>Lock</span>} {showExpanded && <span>Lock</span>}
</button> </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 && ( {isAdmin && (
<NavLink <NavLink
to="/admin" 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 { 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 type { LucideIcon } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format, parseISO, differenceInYears } from 'date-fns'; import { format, parseISO, differenceInYears } from 'date-fns';
@ -23,6 +23,9 @@ import {
import { useTableVisibility } from '@/hooks/useTableVisibility'; import { useTableVisibility } from '@/hooks/useTableVisibility';
import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import { useCategoryOrder } from '@/hooks/useCategoryOrder';
import PersonForm from './PersonForm'; import PersonForm from './PersonForm';
import ConnectionSearch from '@/components/connections/ConnectionSearch';
import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard';
import { useConnections } from '@/hooks/useConnections';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// StatCounter — inline helper // StatCounter — inline helper
@ -57,7 +60,11 @@ function StatCounter({
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getPersonInitialsName(p: Person): string { 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; return parts.length > 0 ? parts.join(' ') : p.name;
} }
@ -82,6 +89,14 @@ function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Column definitions // 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>[] = [ const columns: ColumnDef<Person>[] = [
{ {
key: 'name', key: 'name',
@ -89,7 +104,10 @@ const columns: ColumnDef<Person>[] = [
sortable: true, sortable: true,
visibilityLevel: 'essential', visibilityLevel: 'essential',
render: (p) => { 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 ( return (
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div <div
@ -97,7 +115,10 @@ const columns: ColumnDef<Person>[] = [
> >
{getInitials(initialsName)} {getInitials(initialsName)}
</div> </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> </div>
); );
}, },
@ -107,18 +128,21 @@ const columns: ColumnDef<Person>[] = [
label: 'Number', label: 'Number',
sortable: false, sortable: false,
visibilityLevel: 'essential', visibilityLevel: 'essential',
render: (p) => ( render: (p) => {
<span className="text-muted-foreground truncate">{p.mobile || p.phone || '—'}</span> const mobile = sf(p, 'mobile');
), const phone = sf(p, 'phone');
return <span className="text-muted-foreground truncate">{mobile || phone || '—'}</span>;
},
}, },
{ {
key: 'email', key: 'email',
label: 'Email', label: 'Email',
sortable: true, sortable: true,
visibilityLevel: 'essential', visibilityLevel: 'essential',
render: (p) => ( render: (p) => {
<span className="text-muted-foreground truncate">{p.email || '—'}</span> const email = sf(p, 'email');
), return <span className="text-muted-foreground truncate">{email || '—'}</span>;
},
}, },
{ {
key: 'job_title', key: 'job_title',
@ -126,10 +150,10 @@ const columns: ColumnDef<Person>[] = [
sortable: true, sortable: true,
visibilityLevel: 'filtered', visibilityLevel: 'filtered',
render: (p) => { render: (p) => {
const parts = [p.job_title, p.company].filter(Boolean); const jobTitle = sf(p, 'job_title');
return ( const company = sf(p, 'company');
<span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span> 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', label: 'Birthday',
sortable: true, sortable: true,
visibilityLevel: 'filtered', visibilityLevel: 'filtered',
render: (p) => render: (p) => {
p.birthday ? ( const birthday = sf(p, 'birthday');
<span className="text-muted-foreground">{format(parseISO(p.birthday), 'MMM d')}</span> return birthday ? (
<span className="text-muted-foreground">{format(parseISO(birthday), 'MMM d')}</span>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
), );
},
}, },
{ {
key: 'category', key: 'category',
@ -170,6 +196,7 @@ const columns: ColumnDef<Person>[] = [
// Panel field config // Panel field config
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const panelFields: PanelField[] = [ const panelFields: PanelField[] = [
{ label: 'Preferred Name', key: 'preferred_name', icon: User2 },
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone }, { label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone }, { label: 'Phone', key: 'phone', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail }, { label: 'Email', key: 'email', copyable: true, icon: Mail },
@ -193,9 +220,17 @@ export default function PeoplePage() {
const [editingPerson, setEditingPerson] = useState<Person | null>(null); const [editingPerson, setEditingPerson] = useState<Person | null>(null);
const [activeFilters, setActiveFilters] = useState<string[]>([]); const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [showPinned, setShowPinned] = useState(true); const [showPinned, setShowPinned] = useState(true);
const [showUmbralOnly, setShowUmbralOnly] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<string>('name'); const [sortKey, setSortKey] = useState<string>('name');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); 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({ const { data: people = [], isLoading } = useQuery({
queryKey: ['people'], queryKey: ['people'],
@ -228,6 +263,10 @@ export default function PeoplePage() {
? people.filter((p) => !p.is_favourite) ? people.filter((p) => !p.is_favourite)
: people; : people;
if (showUmbralOnly) {
list = list.filter((p) => p.is_umbral_contact);
}
if (activeFilters.length > 0) { if (activeFilters.length > 0) {
list = list.filter((p) => p.category && activeFilters.includes(p.category)); list = list.filter((p) => p.category && activeFilters.includes(p.category));
} }
@ -249,7 +288,7 @@ export default function PeoplePage() {
} }
return sortPeople(list, sortKey, sortDir); 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 // Build row groups for the table — ordered by custom category order
const groups = useMemo(() => { const groups = useMemo(() => {
@ -314,6 +353,7 @@ export default function PeoplePage() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] }); queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Person deleted'); 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 // Toggle favourite mutation
const toggleFavouriteMutation = useMutation({ const toggleFavouriteMutation = useMutation({
mutationFn: async (person: Person) => { mutationFn: async (person: Person) => {
@ -347,6 +403,18 @@ export default function PeoplePage() {
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
}, [panelOpen]); }, [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 = () => { const handleCloseForm = () => {
setShowForm(false); setShowForm(false);
setEditingPerson(null); setEditingPerson(null);
@ -363,17 +431,75 @@ export default function PeoplePage() {
{getInitials(initialsName)} {getInitials(initialsName)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3> <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 && ( {p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span> <span className="text-xs text-muted-foreground">{p.category}</span>
)} )}
</div> </div>
</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 => { 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) { if (key === 'birthday_display' && p.birthday) {
const age = differenceInYears(new Date(), parseISO(p.birthday)); const age = differenceInYears(new Date(), parseISO(p.birthday));
return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`; return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`;
@ -385,7 +511,7 @@ export default function PeoplePage() {
const renderPanel = () => ( const renderPanel = () => (
<EntityDetailPanel<Person> <EntityDetailPanel<Person>
item={selectedPerson} item={selectedPerson}
fields={panelFields} fields={dynamicPanelFields}
onEdit={() => { onEdit={() => {
setEditingPerson(selectedPerson); setEditingPerson(selectedPerson);
setShowForm(true); setShowForm(true);
@ -399,6 +525,30 @@ export default function PeoplePage() {
isFavourite={selectedPerson?.is_favourite} isFavourite={selectedPerson?.is_favourite}
onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)} onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)}
favouriteLabel="favourite" 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} onReorderCategories={reorderCategories}
searchValue={search} searchValue={search}
onSearchChange={setSearch} onSearchChange={setSearch}
extraPinnedFilters={[
{
label: 'Umbral',
isActive: showUmbralOnly,
onToggle: () => setShowUmbralOnly((p) => !p),
},
]}
/> />
</div> </div>
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person"> <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" /> <Plus className="mr-2 h-4 w-4" />
Add Person Add Person
</Button> </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>
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col">
@ -472,6 +663,40 @@ export default function PeoplePage() {
</div> </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 */} {/* Main content: table + panel */}
<div className="flex-1 overflow-hidden flex"> <div className="flex-1 overflow-hidden flex">
{/* Table */} {/* Table */}
@ -558,6 +783,17 @@ export default function PeoplePage() {
onClose={handleCloseForm} onClose={handleCloseForm}
/> />
)} )}
<ConnectionSearch
open={showConnectionSearch}
onOpenChange={setShowConnectionSearch}
/>
<ConnectionSearch
open={linkPersonId !== null}
onOpenChange={(open) => { if (!open) setLinkPersonId(null); }}
personId={linkPersonId ?? undefined}
/>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
import { useState, useMemo, FormEvent } from 'react'; import { useState, useMemo, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; 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 { parseISO, differenceInYears } from 'date-fns';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types'; import type { Person } from '@/types';
@ -30,6 +30,11 @@ interface PersonFormProps {
export default function PersonForm({ person, categories, onClose }: PersonFormProps) { export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
const queryClient = useQueryClient(); 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({ const [formData, setFormData] = useState({
first_name: first_name:
person?.first_name || person?.first_name ||
@ -38,20 +43,24 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
person?.last_name || person?.last_name ||
(person?.name ? splitName(person.name).lastName : ''), (person?.name ? splitName(person.name).lastName : ''),
nickname: person?.nickname || '', nickname: person?.nickname || '',
email: person?.email || '', email: shared('email', person?.email || ''),
phone: person?.phone || '', phone: shared('phone', person?.phone || ''),
mobile: person?.mobile || '', mobile: shared('mobile', person?.mobile || ''),
address: person?.address || '', address: shared('address', person?.address || ''),
birthday: person?.birthday birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''),
? person.birthday.slice(0, 10)
: '',
category: person?.category || '', category: person?.category || '',
is_favourite: person?.is_favourite ?? false, is_favourite: person?.is_favourite ?? false,
company: person?.company || '', company: shared('company', person?.company || ''),
job_title: person?.job_title || '', job_title: shared('job_title', person?.job_title || ''),
notes: person?.notes || '', 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(() => { const age = useMemo(() => {
if (!formData.birthday) return null; if (!formData.birthday) return null;
try { try {
@ -165,13 +174,25 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
{/* Row 4: Birthday + Age */} {/* Row 4: Birthday + Age */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <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 <DatePicker
variant="input" variant="input"
id="birthday" id="birthday"
value={formData.birthday} value={formData.birthday}
onChange={(v) => set('birthday', v)} onChange={(v) => set('birthday', v)}
/> />
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="age">Age</Label> <Label htmlFor="age">Age</Label>
@ -200,40 +221,66 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
{/* Row 6: Mobile + Email */} {/* Row 6: Mobile + Email */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <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 <Input
id="mobile" id="mobile"
type="tel" type="tel"
value={formData.mobile} value={formData.mobile}
onChange={(e) => set('mobile', e.target.value)} onChange={(e) => set('mobile', e.target.value)}
disabled={isShared('mobile')}
className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''}
/> />
</div> </div>
<div className="space-y-2"> <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 <Input
id="email" id="email"
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => set('email', e.target.value)} onChange={(e) => set('email', e.target.value)}
disabled={isShared('email')}
className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''}
/> />
</div> </div>
</div> </div>
{/* Row 7: Phone */} {/* Row 7: Phone */}
<div className="space-y-2"> <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 <Input
id="phone" id="phone"
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => set('phone', e.target.value)} onChange={(e) => set('phone', e.target.value)}
placeholder="Landline / work number" placeholder="Landline / work number"
disabled={isShared('phone')}
className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''}
/> />
</div> </div>
{/* Row 8: Address */} {/* Row 8: Address */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="address">Address</Label> <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 <LocationPicker
id="address" id="address"
value={formData.address} value={formData.address}
@ -241,24 +288,35 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
onSelect={(result) => set('address', result.address || result.name)} onSelect={(result) => set('address', result.address || result.name)}
placeholder="Search or enter address..." placeholder="Search or enter address..."
/> />
)}
</div> </div>
{/* Row 9: Company + Job Title */} {/* Row 9: Company + Job Title */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <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 <Input
id="company" id="company"
value={formData.company} value={formData.company}
onChange={(e) => set('company', e.target.value)} onChange={(e) => set('company', e.target.value)}
disabled={isShared('company')}
className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''}
/> />
</div> </div>
<div className="space-y-2"> <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 <Input
id="job_title" id="job_title"
value={formData.job_title} value={formData.job_title}
onChange={(e) => set('job_title', e.target.value)} onChange={(e) => set('job_title', e.target.value)}
disabled={isShared('job_title')}
className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''}
/> />
</div> </div>
</div> </div>

View File

@ -14,6 +14,7 @@ import {
Loader2, Loader2,
Shield, Shield,
Blocks, Blocks,
Ghost,
} from 'lucide-react'; } from 'lucide-react';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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 api from '@/lib/api';
import type { GeoLocation, UserProfile } from '@/types'; import type { GeoLocation, UserProfile } from '@/types';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import CopyableField from '@/components/shared/CopyableField';
import TotpSetupSection from './TotpSetupSection'; import TotpSetupSection from './TotpSetupSection';
import NtfySettingsSection from './NtfySettingsSection'; import NtfySettingsSection from './NtfySettingsSection';
@ -55,6 +57,26 @@ export default function SettingsPage() {
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false); const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5); 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) // Profile fields (stored on User model, fetched from /auth/profile)
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['profile'], queryKey: ['profile'],
@ -68,6 +90,8 @@ export default function SettingsPage() {
const [profileEmail, setProfileEmail] = useState(''); const [profileEmail, setProfileEmail] = useState('');
const [dateOfBirth, setDateOfBirth] = useState(''); const [dateOfBirth, setDateOfBirth] = useState('');
const [emailError, setEmailError] = useState<string | null>(null); const [emailError, setEmailError] = useState<string | null>(null);
const [umbralName, setUmbralName] = useState('');
const [umbralNameError, setUmbralNameError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (profileQuery.data) { if (profileQuery.data) {
@ -75,6 +99,7 @@ export default function SettingsPage() {
setLastName(profileQuery.data.last_name ?? ''); setLastName(profileQuery.data.last_name ?? '');
setProfileEmail(profileQuery.data.email ?? ''); setProfileEmail(profileQuery.data.email ?? '');
setDateOfBirth(profileQuery.data.date_of_birth ?? ''); setDateOfBirth(profileQuery.data.date_of_birth ?? '');
setUmbralName(profileQuery.data.umbral_name ?? '');
} }
}, [profileQuery.dataUpdatedAt]); }, [profileQuery.dataUpdatedAt]);
@ -87,6 +112,22 @@ export default function SettingsPage() {
setFirstDayOfWeek(settings.first_day_of_week); setFirstDayOfWeek(settings.first_day_of_week);
setAutoLockEnabled(settings.auto_lock_enabled); setAutoLockEnabled(settings.auto_lock_enabled);
setAutoLockMinutes(settings.auto_lock_minutes ?? 5); 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) }, [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 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 }; 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 current = values[field].trim();
const original = profileQuery.data?.[field] ?? ''; const original = profileQuery.data?.[field] ?? '';
if (current === (original || '')) return; if (current === (original || '')) return;
@ -188,6 +229,19 @@ export default function SettingsPage() {
} }
setEmailError(null); 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 { try {
await api.put('/auth/profile', { [field]: current || null }); await api.put('/auth/profile', { [field]: current || null });
queryClient.invalidateQueries({ queryKey: ['profile'] }); queryClient.invalidateQueries({ queryKey: ['profile'] });
@ -196,6 +250,8 @@ export default function SettingsPage() {
const detail = err?.response?.data?.detail; const detail = err?.response?.data?.detail;
if (field === 'email' && detail) { if (field === 'email' && detail) {
setEmailError(typeof detail === 'string' ? detail : 'Failed to update email'); 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 { } else {
toast.error(typeof detail === 'string' ? detail : 'Failed to update profile'); 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 handleAutoLockMinutesSave = async () => {
const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes; const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes;
const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw)); 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'); }} onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
/> />
</div> </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> </CardContent>
</Card> </Card>
@ -586,9 +734,88 @@ export default function SettingsPage() {
</div> </div>
{/* ── Right column: Security, Authentication, Integrations ── */} {/* ── Right column: Social, Security, Authentication, Integrations ── */}
<div className="space-y-6"> <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) */} {/* Security (auto-lock) */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@ -18,6 +18,12 @@ import {
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
interface ExtraPinnedFilter {
label: string;
isActive: boolean;
onToggle: () => void;
}
interface CategoryFilterBarProps { interface CategoryFilterBarProps {
activeFilters: string[]; activeFilters: string[];
pinnedLabel: string; pinnedLabel: string;
@ -30,6 +36,7 @@ interface CategoryFilterBarProps {
onReorderCategories?: (order: string[]) => void; onReorderCategories?: (order: string[]) => void;
searchValue: string; searchValue: string;
onSearchChange: (val: string) => void; onSearchChange: (val: string) => void;
extraPinnedFilters?: ExtraPinnedFilter[];
} }
const pillBase = const pillBase =
@ -116,6 +123,7 @@ export default function CategoryFilterBar({
onReorderCategories, onReorderCategories,
searchValue, searchValue,
onSearchChange, onSearchChange,
extraPinnedFilters = [],
}: CategoryFilterBarProps) { }: CategoryFilterBarProps) {
const [otherOpen, setOtherOpen] = useState(false); const [otherOpen, setOtherOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
@ -169,6 +177,22 @@ export default function CategoryFilterBar({
</span> </span>
</button> </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 pill + expandable chips */}
{categories.length > 0 && ( {categories.length > 0 && (
<> <>

View File

@ -27,6 +27,7 @@ interface EntityDetailPanelProps<T> {
isFavourite?: boolean; isFavourite?: boolean;
onToggleFavourite?: () => void; onToggleFavourite?: () => void;
favouriteLabel?: string; favouriteLabel?: string;
extraActions?: (item: T) => React.ReactNode;
} }
export function EntityDetailPanel<T>({ export function EntityDetailPanel<T>({
@ -42,6 +43,7 @@ export function EntityDetailPanel<T>({
isFavourite, isFavourite,
onToggleFavourite, onToggleFavourite,
favouriteLabel = 'favourite', favouriteLabel = 'favourite',
extraActions,
}: EntityDetailPanelProps<T>) { }: EntityDetailPanelProps<T>) {
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete); const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
@ -134,7 +136,10 @@ export function EntityDetailPanel<T>({
{/* Footer */} {/* Footer */}
<div className="px-5 py-4 border-t border-border flex items-center justify-between"> <div className="px-5 py-4 border-t border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span> <span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
{extraActions?.(item)}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" 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 settings
auto_lock_enabled: boolean; auto_lock_enabled: boolean;
auto_lock_minutes: number; 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; created_at: string;
updated_at: string; updated_at: string;
} }
@ -171,6 +192,9 @@ export interface Person {
company?: string; company?: string;
job_title?: string; job_title?: string;
notes?: string; notes?: string;
linked_user_id?: number | null;
is_umbral_contact: boolean;
shared_fields?: Record<string, unknown> | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -222,6 +246,7 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo
export interface AdminUser { export interface AdminUser {
id: number; id: number;
username: string; username: string;
umbral_name: string;
email: string | null; email: string | null;
first_name: string | null; first_name: string | null;
last_name: string | null; last_name: string | null;
@ -348,6 +373,7 @@ export interface UpcomingResponse {
export interface UserProfile { export interface UserProfile {
username: string; username: string;
umbral_name: string;
email: string | null; email: string | null;
first_name: string | null; first_name: string | null;
last_name: string | null; last_name: string | null;
@ -366,3 +392,50 @@ export interface EventTemplate {
is_starred: boolean; is_starred: boolean;
created_at: string; 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;
}