diff --git a/backend/alembic/versions/039_add_umbral_name_to_users.py b/backend/alembic/versions/039_add_umbral_name_to_users.py new file mode 100644 index 0000000..8c02992 --- /dev/null +++ b/backend/alembic/versions/039_add_umbral_name_to_users.py @@ -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") diff --git a/backend/alembic/versions/040_expand_settings_profile_social.py b/backend/alembic/versions/040_expand_settings_profile_social.py new file mode 100644 index 0000000..047c8c0 --- /dev/null +++ b/backend/alembic/versions/040_expand_settings_profile_social.py @@ -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") diff --git a/backend/alembic/versions/041_create_notifications_table.py b/backend/alembic/versions/041_create_notifications_table.py new file mode 100644 index 0000000..466577f --- /dev/null +++ b/backend/alembic/versions/041_create_notifications_table.py @@ -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") diff --git a/backend/alembic/versions/042_create_connection_tables.py b/backend/alembic/versions/042_create_connection_tables.py new file mode 100644 index 0000000..b10d4ce --- /dev/null +++ b/backend/alembic/versions/042_create_connection_tables.py @@ -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") diff --git a/backend/alembic/versions/043_add_linked_fields_to_people.py b/backend/alembic/versions/043_add_linked_fields_to_people.py new file mode 100644 index 0000000..364aaf4 --- /dev/null +++ b/backend/alembic/versions/043_add_linked_fields_to_people.py @@ -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") diff --git a/backend/alembic/versions/044_add_notification_type_check.py b/backend/alembic/versions/044_add_notification_type_check.py new file mode 100644 index 0000000..7f45b14 --- /dev/null +++ b/backend/alembic/versions/044_add_notification_type_check.py @@ -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") diff --git a/backend/alembic/versions/045_add_share_name_fields.py b/backend/alembic/versions/045_add_share_name_fields.py new file mode 100644 index 0000000..674060a --- /dev/null +++ b/backend/alembic/versions/045_add_share_name_fields.py @@ -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") diff --git a/backend/alembic/versions/046_add_person_id_to_connection_requests.py b/backend/alembic/versions/046_add_person_id_to_connection_requests.py new file mode 100644 index 0000000..5dcca47 --- /dev/null +++ b/backend/alembic/versions/046_add_person_id_to_connection_requests.py @@ -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") diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index be463f4..ca15d2f 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload from app.database import AsyncSessionLocal from app.models.settings import Settings +from app.models.notification import Notification as AppNotification from app.models.reminder import Reminder from app.models.calendar_event import CalendarEvent from app.models.calendar import Calendar @@ -25,6 +26,7 @@ from app.models.project import Project from app.models.ntfy_sent import NtfySent from app.models.totp_usage import TOTPUsage from app.models.session import UserSession +from app.models.connection_request import ConnectionRequest from app.services.ntfy import send_ntfy_notification from app.services.ntfy_templates import ( build_event_notification, @@ -267,6 +269,37 @@ async def _purge_expired_sessions(db: AsyncSession) -> None: await db.commit() +async def _purge_old_notifications(db: AsyncSession) -> None: + """Remove in-app notifications older than 90 days.""" + cutoff = datetime.now() - timedelta(days=90) + await db.execute(delete(AppNotification).where(AppNotification.created_at < cutoff)) + await db.commit() + + +async def _purge_resolved_requests(db: AsyncSession) -> None: + """Remove resolved connection requests after retention period. + + Rejected/cancelled: 30 days. Accepted: 90 days (longer for audit trail). + resolved_at must be set when changing status. NULL resolved_at rows are + preserved (comparison with NULL yields NULL). + """ + reject_cutoff = datetime.now() - timedelta(days=30) + accept_cutoff = datetime.now() - timedelta(days=90) + await db.execute( + delete(ConnectionRequest).where( + ConnectionRequest.status.in_(["rejected", "cancelled"]), + ConnectionRequest.resolved_at < reject_cutoff, + ) + ) + await db.execute( + delete(ConnectionRequest).where( + ConnectionRequest.status == "accepted", + ConnectionRequest.resolved_at < accept_cutoff, + ) + ) + await db.commit() + + # ── Entry point ─────────────────────────────────────────────────────────────── async def run_notification_dispatch() -> None: @@ -308,6 +341,8 @@ async def run_notification_dispatch() -> None: async with AsyncSessionLocal() as db: await _purge_totp_usage(db) await _purge_expired_sessions(db) + await _purge_old_notifications(db) + await _purge_resolved_requests(db) except Exception: # Broad catch: job failure must never crash the scheduler or the app diff --git a/backend/app/main.py b/backend/app/main.py index 4f1ddcd..768e19c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates -from app.routers import totp, admin +from app.routers import totp, admin, notifications as notifications_router, connections as connections_router from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -17,6 +17,9 @@ from app.models import totp_usage as _totp_usage_model # noqa: F401 from app.models import backup_code as _backup_code_model # noqa: F401 from app.models import system_config as _system_config_model # noqa: F401 from app.models import audit_log as _audit_log_model # noqa: F401 +from app.models import notification as _notification_model # noqa: F401 +from app.models import connection_request as _connection_request_model # noqa: F401 +from app.models import user_connection as _user_connection_model # noqa: F401 # --------------------------------------------------------------------------- @@ -129,6 +132,8 @@ app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) +app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"]) +app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"]) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d9d7a34..0b96dc8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,9 @@ from app.models.totp_usage import TOTPUsage from app.models.backup_code import BackupCode from app.models.system_config import SystemConfig from app.models.audit_log import AuditLog +from app.models.notification import Notification +from app.models.connection_request import ConnectionRequest +from app.models.user_connection import UserConnection __all__ = [ "Settings", @@ -34,4 +37,7 @@ __all__ = [ "BackupCode", "SystemConfig", "AuditLog", + "Notification", + "ConnectionRequest", + "UserConnection", ] diff --git a/backend/app/models/connection_request.py b/backend/app/models/connection_request.py new file mode 100644 index 0000000..78ae150 --- /dev/null +++ b/backend/app/models/connection_request.py @@ -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") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..58d71c3 --- /dev/null +++ b/backend/app/models/notification.py @@ -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()) diff --git a/backend/app/models/person.py b/backend/app/models/person.py index 66c3acd..1c02e43 100644 --- a/backend/app/models/person.py +++ b/backend/app/models/person.py @@ -27,6 +27,11 @@ class Person(Base): job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + # Umbral contact link + linked_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + is_umbral_contact: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text('false')) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index cf5ea87..2f3128a 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, func +from sqlalchemy import String, Text, Integer, Float, Boolean, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from typing import Optional @@ -46,6 +46,31 @@ class Settings(Base): auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5") + # Profile fields (shareable with connections) + phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None) + mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None) + address: Mapped[Optional[str]] = mapped_column(Text, nullable=True, default=None) + company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None) + job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None) + + # Social settings + accept_connections: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + + # Sharing defaults (what fields are shared with connections by default) + share_first_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_last_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_preferred_name: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + share_email: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_phone: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_mobile: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_birthday: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_address: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_company: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_job_title: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + + # ntfy connection notification toggle (gates push only, not in-app) + ntfy_connections_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + @property def ntfy_has_token(self) -> bool: """Derived field for SettingsResponse — True when an auth token is stored.""" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index cc9a181..64e36e0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -9,6 +9,7 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True, index=True) username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + umbral_name: Mapped[str] = mapped_column(String(50), unique=True, index=True) email: Mapped[str | None] = mapped_column(String(255), nullable=True) first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) diff --git a/backend/app/models/user_connection.py b/backend/app/models/user_connection.py new file mode 100644 index 0000000..05f97a2 --- /dev/null +++ b/backend/app/models/user_connection.py @@ -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") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 35e863c..56ead05 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -70,10 +70,21 @@ def _target_username_col(target_alias, audit_model): COALESCE: prefer the live username from the users table, fall back to the username stored in the audit detail JSON (survives user deletion since audit_log.target_user_id → SET NULL). + Guard the JSONB cast with a CASE to avoid errors on non-JSON detail values. """ + json_fallback = sa.case( + ( + sa.and_( + audit_model.detail.is_not(None), + audit_model.detail.startswith("{"), + ), + sa.cast(audit_model.detail, JSONB)["username"].as_string(), + ), + else_=sa.null(), + ) return sa.func.coalesce( target_alias.username, - sa.cast(audit_model.detail, JSONB)["username"].as_string(), + json_fallback, ).label("target_username") @@ -170,9 +181,9 @@ async def get_user( ) active_sessions = session_result.scalar_one() - # Fetch preferred_name from Settings + # Fetch preferred_name from Settings (limit 1 defensive) settings_result = await db.execute( - sa.select(Settings.preferred_name).where(Settings.user_id == user_id) + sa.select(Settings.preferred_name).where(Settings.user_id == user_id).limit(1) ) preferred_name = settings_result.scalar_one_or_none() @@ -181,6 +192,8 @@ async def get_user( active_sessions=active_sessions, preferred_name=preferred_name, date_of_birth=user.date_of_birth, + must_change_password=user.must_change_password, + locked_until=user.locked_until, ) @@ -209,6 +222,7 @@ async def create_user( new_user = User( username=data.username, + umbral_name=data.username, password_hash=hash_password(data.password), role=data.role, email=email, @@ -241,6 +255,10 @@ async def create_user( return UserDetailResponse( **UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}), active_sessions=0, + preferred_name=data.preferred_name, + date_of_birth=None, + must_change_password=new_user.must_change_password, + locked_until=new_user.locked_until, ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 85a854e..9989812 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -288,6 +288,7 @@ async def setup( password_hash = hash_password(data.password) new_user = User( username=data.username, + umbral_name=data.username, password_hash=password_hash, role="admin", last_password_change_at=datetime.now(), @@ -440,7 +441,7 @@ async def register( select(User).where(User.username == data.username) ) if existing.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.") + raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.") # Check email uniqueness (generic error to prevent enumeration) if data.email: @@ -454,6 +455,7 @@ async def register( # SEC-01: Explicit field assignment — never **data.model_dump() new_user = User( username=data.username, + umbral_name=data.username, password_hash=password_hash, role="standard", email=data.email, @@ -666,6 +668,15 @@ async def update_profile( if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Email is already in use") + # Umbral name uniqueness check if changing + if "umbral_name" in update_data and update_data["umbral_name"] != current_user.umbral_name: + new_name = update_data["umbral_name"] + existing = await db.execute( + select(User).where(User.umbral_name == new_name, User.id != current_user.id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Umbral name is already taken") + # SEC-01: Explicit field assignment — only allowed profile fields if "first_name" in update_data: current_user.first_name = update_data["first_name"] @@ -675,6 +686,8 @@ async def update_profile( current_user.email = update_data["email"] if "date_of_birth" in update_data: current_user.date_of_birth = update_data["date_of_birth"] + if "umbral_name" in update_data: + current_user.umbral_name = update_data["umbral_name"] await log_audit_event( db, action="auth.profile_updated", actor_id=current_user.id, diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py new file mode 100644 index 0000000..26b72bd --- /dev/null +++ b/backend/app/routers/connections.py @@ -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 diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py new file mode 100644 index 0000000..b6418e3 --- /dev/null +++ b/backend/app/routers/notifications.py @@ -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 diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 2c1b517..6420e52 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -1,14 +1,18 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ +from sqlalchemy.orm import selectinload from datetime import datetime, timezone from typing import Optional, List from app.database import get_db from app.models.person import Person +from app.models.settings import Settings +from app.models.user import User +from app.models.user_connection import UserConnection from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse from app.routers.auth import get_current_user -from app.models.user import User +from app.services.connection import detach_umbral_contact, resolve_shared_profile router = APIRouter() @@ -59,6 +63,62 @@ async def get_people( result = await db.execute(query) people = result.scalars().all() + # Batch-load shared profiles for umbral contacts + umbral_people = [p for p in people if p.linked_user_id is not None] + if umbral_people: + linked_user_ids = [p.linked_user_id for p in umbral_people] + + # Batch fetch users and settings + users_result = await db.execute( + select(User).where(User.id.in_(linked_user_ids)) + ) + users_by_id = {u.id: u for u in users_result.scalars().all()} + + settings_result = await db.execute( + select(Settings).where(Settings.user_id.in_(linked_user_ids)) + ) + settings_by_user = {s.user_id: s for s in settings_result.scalars().all()} + + # Batch fetch connection overrides + conns_result = await db.execute( + select(UserConnection).where( + UserConnection.user_id == current_user.id, + UserConnection.connected_user_id.in_(linked_user_ids), + ) + ) + overrides_by_user = { + c.connected_user_id: c.sharing_overrides + for c in conns_result.scalars().all() + } + + # Build shared profiles and track remote timestamps separately + shared_profiles: dict[int, dict] = {} + remote_timestamps: dict[int, datetime] = {} + for uid in linked_user_ids: + user = users_by_id.get(uid) + user_settings = settings_by_user.get(uid) + if user and user_settings: + shared_profiles[uid] = resolve_shared_profile( + user, user_settings, overrides_by_user.get(uid) + ) + # umbral_name is always visible (public identity), not a shareable field + shared_profiles[uid]["umbral_name"] = user.umbral_name + if user.updated_at and user_settings.updated_at: + remote_timestamps[uid] = max(user.updated_at, user_settings.updated_at) + + # Attach to response + responses = [] + for p in people: + resp = PersonResponse.model_validate(p) + if p.linked_user_id and p.linked_user_id in shared_profiles: + resp.shared_fields = shared_profiles[p.linked_user_id] + # Show the latest update time across local record and connected user's profile + remote_updated = remote_timestamps.get(p.linked_user_id) + if remote_updated and remote_updated > p.updated_at: + resp.updated_at = remote_updated + responses.append(resp) + return responses + return people @@ -104,7 +164,34 @@ async def get_person( if not person: raise HTTPException(status_code=404, detail="Person not found") - return person + resp = PersonResponse.model_validate(person) + if person.linked_user_id: + linked_user_result = await db.execute( + select(User).where(User.id == person.linked_user_id) + ) + linked_user = linked_user_result.scalar_one_or_none() + linked_settings_result = await db.execute( + select(Settings).where(Settings.user_id == person.linked_user_id) + ) + linked_settings = linked_settings_result.scalar_one_or_none() + conn_result = await db.execute( + select(UserConnection).where( + UserConnection.user_id == current_user.id, + UserConnection.connected_user_id == person.linked_user_id, + ) + ) + conn = conn_result.scalar_one_or_none() + if linked_user and linked_settings: + resp.shared_fields = resolve_shared_profile( + linked_user, linked_settings, conn.sharing_overrides if conn else None + ) + resp.shared_fields["umbral_name"] = linked_user.umbral_name + # Show the latest update time across local record and connected user's profile + if linked_user.updated_at and linked_settings.updated_at: + remote_updated = max(linked_user.updated_at, linked_settings.updated_at) + if remote_updated > person.updated_at: + resp.updated_at = remote_updated + return resp @router.put("/{person_id}", response_model=PersonResponse) @@ -144,13 +231,79 @@ async def update_person( return person +async def _sever_connection(db: AsyncSession, current_user: User, person: Person) -> None: + """Remove bidirectional UserConnection rows and detach the counterpart's Person.""" + if not person.linked_user_id: + return + + counterpart_id = person.linked_user_id + + # Find our connection + conn_result = await db.execute( + select(UserConnection).where( + UserConnection.user_id == current_user.id, + UserConnection.connected_user_id == counterpart_id, + ) + ) + our_conn = conn_result.scalar_one_or_none() + + # Find reverse connection + reverse_result = await db.execute( + select(UserConnection).where( + UserConnection.user_id == counterpart_id, + UserConnection.connected_user_id == current_user.id, + ) + ) + reverse_conn = reverse_result.scalar_one_or_none() + + # Detach the counterpart's Person record (if it exists) + if reverse_conn and reverse_conn.person_id: + cp_result = await db.execute( + select(Person).where(Person.id == reverse_conn.person_id) + ) + cp_person = cp_result.scalar_one_or_none() + if cp_person: + await detach_umbral_contact(cp_person) + + # Delete both connection rows + if our_conn: + await db.delete(our_conn) + if reverse_conn: + await db.delete(reverse_conn) + + +@router.put("/{person_id}/unlink", response_model=PersonResponse) +async def unlink_person( + person_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Unlink an umbral contact — convert to standard contact and sever the connection.""" + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) + person = result.scalar_one_or_none() + + if not person: + raise HTTPException(status_code=404, detail="Person not found") + if not person.is_umbral_contact: + raise HTTPException(status_code=400, detail="Person is not an umbral contact") + + await _sever_connection(db, current_user, person) + await detach_umbral_contact(person) + + await db.commit() + await db.refresh(person) + return person + + @router.delete("/{person_id}", status_code=204) async def delete_person( person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Delete a person.""" + """Delete a person. If umbral contact, also severs the bidirectional connection.""" result = await db.execute( select(Person).where(Person.id == person_id, Person.user_id == current_user.id) ) @@ -159,6 +312,9 @@ async def delete_person( if not person: raise HTTPException(status_code=404, detail="Person not found") + if person.is_umbral_contact: + await _sever_connection(db, current_user, person) + await db.delete(person) await db.commit() diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index e6b205a..c99de7f 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -39,6 +39,27 @@ def _to_settings_response(s: Settings) -> SettingsResponse: ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value auto_lock_enabled=s.auto_lock_enabled, auto_lock_minutes=s.auto_lock_minutes, + # Profile fields + phone=s.phone, + mobile=s.mobile, + address=s.address, + company=s.company, + job_title=s.job_title, + # Social settings + accept_connections=s.accept_connections, + # Sharing defaults + share_first_name=s.share_first_name, + share_last_name=s.share_last_name, + share_preferred_name=s.share_preferred_name, + share_email=s.share_email, + share_phone=s.share_phone, + share_mobile=s.share_mobile, + share_birthday=s.share_birthday, + share_address=s.share_address, + share_company=s.share_company, + share_job_title=s.share_job_title, + # ntfy connections toggle + ntfy_connections_enabled=s.ntfy_connections_enabled, created_at=s.created_at, updated_at=s.updated_at, ) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index fa52d2a..bfc4441 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -20,6 +20,7 @@ from app.schemas.auth import _validate_username, _validate_password_strength, _v class UserListItem(BaseModel): id: int username: str + umbral_name: str = "" email: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 29e178b..ebb9a7e 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -172,6 +172,19 @@ class ProfileUpdate(BaseModel): last_name: str | None = Field(None, max_length=100) email: str | None = Field(None, max_length=254) date_of_birth: date | None = None + umbral_name: str | None = Field(None, min_length=3, max_length=50) + + @field_validator("umbral_name") + @classmethod + def validate_umbral_name(cls, v: str | None) -> str | None: + if v is None: + return v + import re + if ' ' in v: + raise ValueError('Umbral name must be a single word with no spaces') + if not re.match(r'^[a-zA-Z0-9_.-]{3,50}$', v): + raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores') + return v @field_validator("email") @classmethod @@ -199,6 +212,7 @@ class ProfileResponse(BaseModel): model_config = ConfigDict(from_attributes=True) username: str + umbral_name: str email: str | None first_name: str | None last_name: str | None diff --git a/backend/app/schemas/connection.py b/backend/app/schemas/connection.py new file mode 100644 index 0000000..01b5078 --- /dev/null +++ b/backend/app/schemas/connection.py @@ -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 diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..80af24a --- /dev/null +++ b/backend/app/schemas/notification.py @@ -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 diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py index 6e2a5e7..188467e 100644 --- a/backend/app/schemas/person.py +++ b/backend/app/schemas/person.py @@ -85,6 +85,9 @@ class PersonResponse(BaseModel): company: Optional[str] job_title: Optional[str] notes: Optional[str] + linked_user_id: Optional[int] = None + is_umbral_contact: bool = False + shared_fields: Optional[dict] = None created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 8f11b60..f78dfc6 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -37,6 +37,31 @@ class SettingsUpdate(BaseModel): auto_lock_enabled: Optional[bool] = None auto_lock_minutes: Optional[int] = None + # Profile fields (shareable with connections) + phone: Optional[str] = Field(None, max_length=50) + mobile: Optional[str] = Field(None, max_length=50) + address: Optional[str] = Field(None, max_length=2000) + company: Optional[str] = Field(None, max_length=255) + job_title: Optional[str] = Field(None, max_length=255) + + # Social settings + accept_connections: Optional[bool] = None + + # Sharing defaults + share_first_name: Optional[bool] = None + share_last_name: Optional[bool] = None + share_preferred_name: Optional[bool] = None + share_email: Optional[bool] = None + share_phone: Optional[bool] = None + share_mobile: Optional[bool] = None + share_birthday: Optional[bool] = None + share_address: Optional[bool] = None + share_company: Optional[bool] = None + share_job_title: Optional[bool] = None + + # ntfy connections toggle + ntfy_connections_enabled: Optional[bool] = None + @field_validator('auto_lock_minutes') @classmethod def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]: @@ -151,6 +176,31 @@ class SettingsResponse(BaseModel): auto_lock_enabled: bool = False auto_lock_minutes: int = 5 + # Profile fields + phone: Optional[str] = None + mobile: Optional[str] = None + address: Optional[str] = None + company: Optional[str] = None + job_title: Optional[str] = None + + # Social settings + accept_connections: bool = False + + # Sharing defaults + share_first_name: bool = False + share_last_name: bool = False + share_preferred_name: bool = True + share_email: bool = False + share_phone: bool = False + share_mobile: bool = False + share_birthday: bool = False + share_address: bool = False + share_company: bool = False + share_job_title: bool = False + + # ntfy connections toggle + ntfy_connections_enabled: bool = True + created_at: datetime updated_at: datetime diff --git a/backend/app/services/connection.py b/backend/app/services/connection.py new file mode 100644 index 0000000..09cbbb9 --- /dev/null +++ b/backend/app/services/connection.py @@ -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"]) diff --git a/backend/app/services/notification.py b/backend/app/services/notification.py new file mode 100644 index 0000000..6093be2 --- /dev/null +++ b/backend/app/services/notification.py @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 17a0f09..52429a0 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,9 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m; # Admin API — generous for legitimate use but still guards against scraping/brute-force limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m; +# Connection endpoints — prevent search enumeration and request spam +limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m; +limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m; # Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access map $http_x_forwarded_proto $forwarded_proto { @@ -82,6 +85,20 @@ server { include /etc/nginx/proxy-params.conf; } + # Connection search — rate-limited to prevent user enumeration + location /api/connections/search { + limit_req zone=conn_search_limit burst=5 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + + # Connection request (send) — exact match to avoid catching /requests/* + location = /api/connections/request { + limit_req zone=conn_request_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + # Admin API — rate-limited separately from general /api traffic location /api/admin/ { limit_req zone=admin_limit burst=10 nodelay; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a969411..66de45d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import ProjectDetail from '@/components/projects/ProjectDetail'; import PeoplePage from '@/components/people/PeoplePage'; import LocationsPage from '@/components/locations/LocationsPage'; import SettingsPage from '@/components/settings/SettingsPage'; +import NotificationsPage from '@/components/notifications/NotificationsPage'; const AdminPortal = lazy(() => import('@/components/admin/AdminPortal')); @@ -72,6 +73,7 @@ function App() { } /> } /> } /> + } /> } /> (''); const PER_PAGE = 25; - const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined); + const { data, isLoading, error } = useAuditLog(page, PER_PAGE, filterAction || undefined); const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1; @@ -111,6 +116,11 @@ export default function ConfigPage() { ))} + ) : error ? ( +
+

Failed to load audit log

+

{error.message}

+
) : !data?.entries?.length ? (

No audit entries found.

) : ( diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx index 5d7d47e..9eb3166 100644 --- a/frontend/src/components/admin/IAMPage.tsx +++ b/frontend/src/components/admin/IAMPage.tsx @@ -167,6 +167,9 @@ export default function IAMPage() { Username + + Umbral Name + Email @@ -209,6 +212,9 @@ export default function IAMPage() { )} > {user.username} + + {user.umbral_name || user.username} + {user.email || '—'} diff --git a/frontend/src/components/admin/UserDetailSection.tsx b/frontend/src/components/admin/UserDetailSection.tsx index 83bdc7a..51261a4 100644 --- a/frontend/src/components/admin/UserDetailSection.tsx +++ b/frontend/src/components/admin/UserDetailSection.tsx @@ -55,7 +55,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean }) } export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) { - const { data: user, isLoading } = useAdminUserDetail(userId); + const { data: user, isLoading, error } = useAdminUserDetail(userId); const updateRole = useUpdateRole(); const handleRoleChange = async (newRole: UserRole) => { @@ -89,6 +89,22 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection ); } + if (error) { + return ( + + +
+

Failed to load user details

+ +
+

{error.message}

+
+
+ ); + } + if (!user) return null; return ( diff --git a/frontend/src/components/connections/ConnectionRequestCard.tsx b/frontend/src/components/connections/ConnectionRequestCard.tsx new file mode 100644 index 0000000..366fff6 --- /dev/null +++ b/frontend/src/components/connections/ConnectionRequestCard.tsx @@ -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 ( +
+ {/* Avatar */} +
+ + {displayName.charAt(0).toUpperCase()} + +
+ + {/* Content */} +
+

{displayName}

+

+ {isIncoming ? 'wants to connect' : 'request pending'} · {formatDistanceToNow(new Date(request.created_at), { addSuffix: true })} +

+
+ + {/* Actions */} +
+ {isIncoming ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/connections/ConnectionSearch.tsx b/frontend/src/components/connections/ConnectionSearch.tsx new file mode 100644 index 0000000..40da4ae --- /dev/null +++ b/frontend/src/components/connections/ConnectionSearch.tsx @@ -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(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 ( + + + + + + Find Umbra User + + + {personId + ? 'Search for an umbral user to link this contact to.' + : 'Search for a user by their umbral name to send a connection request.'} + + +
+ {isLoadingSettings ? ( +
+ ) : !acceptConnectionsEnabled ? ( +
+ +

+ You need to enable Accept Connections in your settings before you can send or receive connection requests. +

+ +
+ ) : ( + <> +
+ +
+ { + setUmbralName(e.target.value); + setFound(null); + setSent(false); + }} + onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} + maxLength={50} + /> + +
+
+ + {found === false && ( +
+ + User not found +
+ )} + + {found === true && !sent && ( +
+
+
+ + {umbralName.charAt(0).toUpperCase()} + +
+ {umbralName} +
+ +
+ )} + + {sent && ( +
+ + Connection request sent +
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 22a173a..7b7a89c 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -4,9 +4,11 @@ import { Menu } from 'lucide-react'; import { useTheme } from '@/hooks/useTheme'; import { AlertsProvider } from '@/hooks/useAlerts'; import { LockProvider } from '@/hooks/useLock'; +import { NotificationProvider } from '@/hooks/useNotifications'; import { Button } from '@/components/ui/button'; import Sidebar from './Sidebar'; import LockOverlay from './LockOverlay'; +import NotificationToaster from '@/components/notifications/NotificationToaster'; export default function AppLayout() { useTheme(); @@ -19,31 +21,34 @@ export default function AppLayout() { return ( -
- { - const next = !collapsed; - setCollapsed(next); - localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next)); - }} - mobileOpen={mobileOpen} - onMobileClose={() => setMobileOpen(false)} - /> -
- {/* Mobile header */} -
- -

UMBRA

+ +
+ { + const next = !collapsed; + setCollapsed(next); + localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next)); + }} + mobileOpen={mobileOpen} + onMobileClose={() => setMobileOpen(false)} + /> +
+ {/* Mobile header */} +
+ +

UMBRA

+
+
+ +
-
- -
-
- + + + ); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 78ea3ed..f41b619 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -22,6 +22,7 @@ import { cn } from '@/lib/utils'; import { useAuth } from '@/hooks/useAuth'; import { useLock } from '@/hooks/useLock'; import { useConfirmAction } from '@/hooks/useConfirmAction'; +import { useNotifications } from '@/hooks/useNotifications'; import { Button } from '@/components/ui/button'; import api from '@/lib/api'; import type { Project } from '@/types'; @@ -47,6 +48,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose const location = useLocation(); const { logout, isAdmin } = useAuth(); const { lock } = useLock(); + const { unreadCount } = useNotifications(); const [projectsExpanded, setProjectsExpanded] = useState(false); const { data: trackedProjects } = useQuery({ @@ -194,6 +196,28 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose {showExpanded && Lock} + +
+ + {unreadCount > 0 && !showExpanded && ( +
+ )} +
+ {showExpanded && ( + + Notifications + {unreadCount > 0 && ( + + {unreadCount} + + )} + + )} + {isAdmin && ( >(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: , + duration: 8000, + }); + } + }); + }, [notifications, handleConnectionRespond]); + + const showConnectionRequestToast = (notification: AppNotification) => { + const requestId = notification.source_id!; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Connection Request

+

+ {notification.message || 'Someone wants to connect with you'} +

+
+ + +
+
+
+
+ ), + { id: `connection-request-${requestId}`, duration: 30000 }, + ); + }; + + return null; +} diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx new file mode 100644 index 0000000..9abd863 --- /dev/null +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -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 = { + 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('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 ( +
+ {/* Page header */} +
+
+
+
+ {/* Filter */} +
+ {(['all', 'unread'] as const).map((f) => ( + + ))} +
+ {unreadCount > 0 && ( + + )} +
+
+ + {/* Content */} +
+ {isLoading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ +

+ {filter === 'unread' ? 'No unread notifications' : 'No notifications'} +

+
+ ) : ( +
+ {filtered.map((notification) => { + const iconConfig = getIcon(notification.type); + const Icon = iconConfig.icon; + return ( +
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 */} +
+ +
+ + {/* Content */} +
+
+
+

+ {notification.title} +

+ {notification.message && ( +

+ {notification.message} +

+ )} +
+ {/* Unread dot */} + {!notification.is_read && ( +
+ )} +
+
+ + {/* Connection request actions (inline) */} + {notification.type === 'connection_request' && + notification.source_id && + pendingRequestIds.has(notification.source_id) && ( +
+ + +
+ )} + + {/* Timestamp + actions */} +
+ + {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })} + +
+ {!notification.is_read && ( + + )} + +
+
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index b38318d..f49b81d 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect } from 'react'; -import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react'; +import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { format, parseISO, differenceInYears } from 'date-fns'; @@ -23,6 +23,9 @@ import { import { useTableVisibility } from '@/hooks/useTableVisibility'; import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import PersonForm from './PersonForm'; +import ConnectionSearch from '@/components/connections/ConnectionSearch'; +import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard'; +import { useConnections } from '@/hooks/useConnections'; // --------------------------------------------------------------------------- // StatCounter — inline helper @@ -57,7 +60,11 @@ function StatCounter({ // Helpers // --------------------------------------------------------------------------- function getPersonInitialsName(p: Person): string { - const parts = [p.first_name, p.last_name].filter(Boolean); + const firstName = p.is_umbral_contact && p.shared_fields?.first_name + ? String(p.shared_fields.first_name) : p.first_name; + const lastName = p.is_umbral_contact && p.shared_fields?.last_name + ? String(p.shared_fields.last_name) : p.last_name; + const parts = [firstName, lastName].filter(Boolean); return parts.length > 0 ? parts.join(' ') : p.name; } @@ -82,6 +89,14 @@ function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[ // --------------------------------------------------------------------------- // Column definitions // --------------------------------------------------------------------------- +/** Get a field value, preferring shared_fields for umbral contacts. */ +function sf(p: Person, key: string): string | null | undefined { + if (p.is_umbral_contact && p.shared_fields && key in p.shared_fields) { + return p.shared_fields[key] as string | null; + } + return p[key as keyof Person] as string | null | undefined; +} + const columns: ColumnDef[] = [ { key: 'name', @@ -89,7 +104,10 @@ const columns: ColumnDef[] = [ sortable: true, visibilityLevel: 'essential', render: (p) => { - const initialsName = getPersonInitialsName(p); + const firstName = sf(p, 'first_name'); + const lastName = sf(p, 'last_name'); + const liveName = [firstName, lastName].filter(Boolean).join(' ') || p.nickname || p.name; + const initialsName = liveName || getPersonInitialsName(p); return (
[] = [ > {getInitials(initialsName)}
- {p.nickname || p.name} + {liveName} + {p.is_umbral_contact && ( + + )}
); }, @@ -107,18 +128,21 @@ const columns: ColumnDef[] = [ label: 'Number', sortable: false, visibilityLevel: 'essential', - render: (p) => ( - {p.mobile || p.phone || '—'} - ), + render: (p) => { + const mobile = sf(p, 'mobile'); + const phone = sf(p, 'phone'); + return {mobile || phone || '—'}; + }, }, { key: 'email', label: 'Email', sortable: true, visibilityLevel: 'essential', - render: (p) => ( - {p.email || '—'} - ), + render: (p) => { + const email = sf(p, 'email'); + return {email || '—'}; + }, }, { key: 'job_title', @@ -126,10 +150,10 @@ const columns: ColumnDef[] = [ sortable: true, visibilityLevel: 'filtered', render: (p) => { - const parts = [p.job_title, p.company].filter(Boolean); - return ( - {parts.join(', ') || '—'} - ); + const jobTitle = sf(p, 'job_title'); + const company = sf(p, 'company'); + const parts = [jobTitle, company].filter(Boolean); + return {parts.join(', ') || '—'}; }, }, { @@ -137,12 +161,14 @@ const columns: ColumnDef[] = [ label: 'Birthday', sortable: true, visibilityLevel: 'filtered', - render: (p) => - p.birthday ? ( - {format(parseISO(p.birthday), 'MMM d')} + render: (p) => { + const birthday = sf(p, 'birthday'); + return birthday ? ( + {format(parseISO(birthday), 'MMM d')} ) : ( - ), + ); + }, }, { key: 'category', @@ -170,6 +196,7 @@ const columns: ColumnDef[] = [ // Panel field config // --------------------------------------------------------------------------- const panelFields: PanelField[] = [ + { label: 'Preferred Name', key: 'preferred_name', icon: User2 }, { label: 'Mobile', key: 'mobile', copyable: true, icon: Phone }, { label: 'Phone', key: 'phone', copyable: true, icon: Phone }, { label: 'Email', key: 'email', copyable: true, icon: Mail }, @@ -193,9 +220,17 @@ export default function PeoplePage() { const [editingPerson, setEditingPerson] = useState(null); const [activeFilters, setActiveFilters] = useState([]); const [showPinned, setShowPinned] = useState(true); + const [showUmbralOnly, setShowUmbralOnly] = useState(false); const [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [showConnectionSearch, setShowConnectionSearch] = useState(false); + const [linkPersonId, setLinkPersonId] = useState(null); + const [showAddDropdown, setShowAddDropdown] = useState(false); + const addDropdownRef = useRef(null); + + const { incomingRequests, outgoingRequests } = useConnections(); + const hasRequests = incomingRequests.length > 0 || outgoingRequests.length > 0; const { data: people = [], isLoading } = useQuery({ queryKey: ['people'], @@ -228,6 +263,10 @@ export default function PeoplePage() { ? people.filter((p) => !p.is_favourite) : people; + if (showUmbralOnly) { + list = list.filter((p) => p.is_umbral_contact); + } + if (activeFilters.length > 0) { list = list.filter((p) => p.category && activeFilters.includes(p.category)); } @@ -249,7 +288,7 @@ export default function PeoplePage() { } return sortPeople(list, sortKey, sortDir); - }, [people, showPinned, activeFilters, search, sortKey, sortDir]); + }, [people, showPinned, showUmbralOnly, activeFilters, search, sortKey, sortDir]); // Build row groups for the table — ordered by custom category order const groups = useMemo(() => { @@ -314,6 +353,7 @@ export default function PeoplePage() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['connections'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success('Person deleted'); @@ -324,6 +364,22 @@ export default function PeoplePage() { }, }); + // Unlink umbral contact mutation + const unlinkMutation = useMutation({ + mutationFn: async (personId: number) => { + const { data } = await api.put(`/people/${personId}/unlink`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['connections'] }); + toast.success('Contact unlinked — converted to standard contact'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to unlink contact')); + }, + }); + // Toggle favourite mutation const toggleFavouriteMutation = useMutation({ mutationFn: async (person: Person) => { @@ -347,6 +403,18 @@ export default function PeoplePage() { return () => document.removeEventListener('keydown', handler); }, [panelOpen]); + // Close add dropdown on outside click + useEffect(() => { + if (!showAddDropdown) return; + const handler = (e: MouseEvent) => { + if (addDropdownRef.current && !addDropdownRef.current.contains(e.target as Node)) { + setShowAddDropdown(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showAddDropdown]); + const handleCloseForm = () => { setShowForm(false); setEditingPerson(null); @@ -363,17 +431,75 @@ export default function PeoplePage() { {getInitials(initialsName)}
-

{p.name}

- {p.category && ( - {p.category} - )} +
+

{ + p.is_umbral_contact && p.shared_fields + ? [sf(p, 'first_name'), sf(p, 'last_name')].filter(Boolean).join(' ') || p.name + : p.name + }

+ {p.is_umbral_contact && ( + + )} +
+
+ {p.is_umbral_contact && p.shared_fields?.umbral_name ? ( + + @{String(p.shared_fields.umbral_name)} + + ) : null} + {p.category && ( + {p.category} + )} +
); }; - // Panel getValue + // Shared field key mapping (panel key -> shared_fields key) + const sharedKeyMap: Record = { + preferred_name: 'preferred_name', + email: 'email', + phone: 'phone', + mobile: 'mobile', + birthday_display: 'birthday', + address: 'address', + company: 'company', + job_title: 'job_title', + }; + + // Build dynamic panel fields with synced labels for shared fields + const dynamicPanelFields = useMemo((): PanelField[] => { + if (!selectedPerson?.is_umbral_contact || !selectedPerson.shared_fields) return panelFields; + const shared = selectedPerson.shared_fields; + return panelFields.map((f) => { + const sharedKey = sharedKeyMap[f.key]; + if (sharedKey && sharedKey in shared) { + return { ...f, label: `${f.label} (synced)` }; + } + return f; + }); + }, [selectedPerson]); + + // Panel getValue — overlays shared fields from connected user const getPanelValue = (p: Person, key: string): string | undefined => { + // Check shared fields first for umbral contacts + if (p.is_umbral_contact && p.shared_fields) { + const sharedKey = sharedKeyMap[key]; + if (sharedKey && sharedKey in p.shared_fields) { + const sharedVal = p.shared_fields[sharedKey]; + if (key === 'birthday_display' && sharedVal) { + const bd = String(sharedVal); + try { + const age = differenceInYears(new Date(), parseISO(bd)); + return `${format(parseISO(bd), 'MMM d, yyyy')} (${age})`; + } catch { + return bd; + } + } + return sharedVal != null ? String(sharedVal) : undefined; + } + } if (key === 'birthday_display' && p.birthday) { const age = differenceInYears(new Date(), parseISO(p.birthday)); return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`; @@ -385,7 +511,7 @@ export default function PeoplePage() { const renderPanel = () => ( item={selectedPerson} - fields={panelFields} + fields={dynamicPanelFields} onEdit={() => { setEditingPerson(selectedPerson); setShowForm(true); @@ -399,6 +525,30 @@ export default function PeoplePage() { isFavourite={selectedPerson?.is_favourite} onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)} favouriteLabel="favourite" + extraActions={(p) => + p.is_umbral_contact ? ( + + ) : ( + + ) + } /> ); @@ -420,12 +570,53 @@ export default function PeoplePage() { onReorderCategories={reorderCategories} searchValue={search} onSearchChange={setSearch} + extraPinnedFilters={[ + { + label: 'Umbral', + isActive: showUmbralOnly, + onToggle: () => setShowUmbralOnly((p) => !p), + }, + ]} />
- +
+
+ + +
+ {showAddDropdown && ( +
+ + +
+ )} +
@@ -472,6 +663,40 @@ export default function PeoplePage() {
)} + {/* Pending requests */} + {hasRequests && ( +
+
+ + Pending Requests + + + {incomingRequests.length + outgoingRequests.length} + +
+
+ {incomingRequests.length > 0 && outgoingRequests.length > 0 && ( +

Incoming

+ )} + {incomingRequests.slice(0, 5).map((req) => ( + + ))} + {incomingRequests.length > 5 && ( +

+{incomingRequests.length - 5} more

+ )} + {incomingRequests.length > 0 && outgoingRequests.length > 0 && ( +

Outgoing

+ )} + {outgoingRequests.slice(0, 5).map((req) => ( + + ))} + {outgoingRequests.length > 5 && ( +

+{outgoingRequests.length - 5} more

+ )} +
+
+ )} + {/* Main content: table + panel */}
{/* Table */} @@ -558,6 +783,17 @@ export default function PeoplePage() { onClose={handleCloseForm} /> )} + + + + { if (!open) setLinkPersonId(null); }} + personId={linkPersonId ?? undefined} + />
); } diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index 2683ae2..18918d8 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { Star, StarOff, X } from 'lucide-react'; +import { Star, StarOff, X, Lock } from 'lucide-react'; import { parseISO, differenceInYears } from 'date-fns'; import api, { getErrorMessage } from '@/lib/api'; import type { Person } from '@/types'; @@ -30,6 +30,11 @@ interface PersonFormProps { export default function PersonForm({ person, categories, onClose }: PersonFormProps) { const queryClient = useQueryClient(); + // Helper to resolve a field value — prefer shared_fields for umbral contacts + const sf = person?.shared_fields; + const shared = (key: string, fallback: string) => + sf && key in sf && sf[key] != null ? String(sf[key]) : fallback; + const [formData, setFormData] = useState({ first_name: person?.first_name || @@ -38,20 +43,24 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr person?.last_name || (person?.name ? splitName(person.name).lastName : ''), nickname: person?.nickname || '', - email: person?.email || '', - phone: person?.phone || '', - mobile: person?.mobile || '', - address: person?.address || '', - birthday: person?.birthday - ? person.birthday.slice(0, 10) - : '', + email: shared('email', person?.email || ''), + phone: shared('phone', person?.phone || ''), + mobile: shared('mobile', person?.mobile || ''), + address: shared('address', person?.address || ''), + birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''), category: person?.category || '', is_favourite: person?.is_favourite ?? false, - company: person?.company || '', - job_title: person?.job_title || '', + company: shared('company', person?.company || ''), + job_title: shared('job_title', person?.job_title || ''), notes: person?.notes || '', }); + // Check if a field is synced from an umbral connection (read-only) + const isShared = (fieldKey: string): boolean => { + if (!person?.is_umbral_contact || !person.shared_fields) return false; + return fieldKey in person.shared_fields; + }; + const age = useMemo(() => { if (!formData.birthday) return null; try { @@ -165,13 +174,25 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr {/* Row 4: Birthday + Age */}
- + + {isShared('birthday') ? ( + + ) : ( set('birthday', v)} /> + )}
@@ -200,65 +221,102 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr {/* Row 6: Mobile + Email */}
- + set('mobile', e.target.value)} + disabled={isShared('mobile')} + className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''} />
- + set('email', e.target.value)} + disabled={isShared('email')} + className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''} />
{/* Row 7: Phone */}
- + set('phone', e.target.value)} placeholder="Landline / work number" + disabled={isShared('phone')} + className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''} />
{/* Row 8: Address */}
- - set('address', val)} - onSelect={(result) => set('address', result.address || result.name)} - placeholder="Search or enter address..." - /> + + {isShared('address') ? ( + + ) : ( + set('address', val)} + onSelect={(result) => set('address', result.address || result.name)} + placeholder="Search or enter address..." + /> + )}
{/* Row 9: Company + Job Title */}
- + set('company', e.target.value)} + disabled={isShared('company')} + className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''} />
- + set('job_title', e.target.value)} + disabled={isShared('job_title')} + className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''} />
diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 6019c78..e76e550 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -14,6 +14,7 @@ import { Loader2, Shield, Blocks, + Ghost, } from 'lucide-react'; import { useSettings } from '@/hooks/useSettings'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -24,6 +25,7 @@ import { cn } from '@/lib/utils'; import api from '@/lib/api'; import type { GeoLocation, UserProfile } from '@/types'; import { Switch } from '@/components/ui/switch'; +import CopyableField from '@/components/shared/CopyableField'; import TotpSetupSection from './TotpSetupSection'; import NtfySettingsSection from './NtfySettingsSection'; @@ -55,6 +57,26 @@ export default function SettingsPage() { const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false); const [autoLockMinutes, setAutoLockMinutes] = useState(settings?.auto_lock_minutes ?? 5); + // Profile extension fields (stored on Settings model) + const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? ''); + const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? ''); + const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? ''); + const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? ''); + const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? ''); + + // Social settings + const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false); + const [shareFirstName, setShareFirstName] = useState(settings?.share_first_name ?? false); + const [shareLastName, setShareLastName] = useState(settings?.share_last_name ?? false); + const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true); + const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false); + const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false); + const [shareMobile, setShareMobile] = useState(settings?.share_mobile ?? false); + const [shareBirthday, setShareBirthday] = useState(settings?.share_birthday ?? false); + const [shareAddress, setShareAddress] = useState(settings?.share_address ?? false); + const [shareCompany, setShareCompany] = useState(settings?.share_company ?? false); + const [shareJobTitle, setShareJobTitle] = useState(settings?.share_job_title ?? false); + // Profile fields (stored on User model, fetched from /auth/profile) const profileQuery = useQuery({ queryKey: ['profile'], @@ -68,6 +90,8 @@ export default function SettingsPage() { const [profileEmail, setProfileEmail] = useState(''); const [dateOfBirth, setDateOfBirth] = useState(''); const [emailError, setEmailError] = useState(null); + const [umbralName, setUmbralName] = useState(''); + const [umbralNameError, setUmbralNameError] = useState(null); useEffect(() => { if (profileQuery.data) { @@ -75,6 +99,7 @@ export default function SettingsPage() { setLastName(profileQuery.data.last_name ?? ''); setProfileEmail(profileQuery.data.email ?? ''); setDateOfBirth(profileQuery.data.date_of_birth ?? ''); + setUmbralName(profileQuery.data.umbral_name ?? ''); } }, [profileQuery.dataUpdatedAt]); @@ -87,6 +112,22 @@ export default function SettingsPage() { setFirstDayOfWeek(settings.first_day_of_week); setAutoLockEnabled(settings.auto_lock_enabled); setAutoLockMinutes(settings.auto_lock_minutes ?? 5); + setSettingsPhone(settings.phone ?? ''); + setSettingsMobile(settings.mobile ?? ''); + setSettingsAddress(settings.address ?? ''); + setSettingsCompany(settings.company ?? ''); + setSettingsJobTitle(settings.job_title ?? ''); + setAcceptConnections(settings.accept_connections); + setShareFirstName(settings.share_first_name); + setShareLastName(settings.share_last_name); + setSharePreferredName(settings.share_preferred_name); + setShareEmail(settings.share_email); + setSharePhone(settings.share_phone); + setShareMobile(settings.share_mobile); + setShareBirthday(settings.share_birthday); + setShareAddress(settings.share_address); + setShareCompany(settings.share_company); + setShareJobTitle(settings.share_job_title); } }, [settings?.id]); // only re-sync on initial load (settings.id won't change) @@ -173,8 +214,8 @@ export default function SettingsPage() { } }; - const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => { - const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth }; + const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth' | 'umbral_name') => { + const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth, umbral_name: umbralName }; const current = values[field].trim(); const original = profileQuery.data?.[field] ?? ''; if (current === (original || '')) return; @@ -188,6 +229,19 @@ export default function SettingsPage() { } setEmailError(null); + // Client-side umbral name validation + if (field === 'umbral_name') { + if (current.includes(' ')) { + setUmbralNameError('Must be a single word with no spaces'); + return; + } + if (!current || !/^[a-zA-Z0-9_-]{3,50}$/.test(current)) { + setUmbralNameError('3-50 characters: letters, numbers, hyphens, underscores'); + return; + } + setUmbralNameError(null); + } + try { await api.put('/auth/profile', { [field]: current || null }); queryClient.invalidateQueries({ queryKey: ['profile'] }); @@ -196,6 +250,8 @@ export default function SettingsPage() { const detail = err?.response?.data?.detail; if (field === 'email' && detail) { setEmailError(typeof detail === 'string' ? detail : 'Failed to update email'); + } else if (field === 'umbral_name' && detail) { + setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name'); } else { toast.error(typeof detail === 'string' ? detail : 'Failed to update profile'); } @@ -248,6 +304,29 @@ export default function SettingsPage() { } }; + const handleSettingsFieldSave = async (field: string, value: string) => { + const trimmed = value.trim(); + const currentVal = (settings as any)?.[field] || ''; + if (trimmed === (currentVal || '')) return; + try { + await updateSettings({ [field]: trimmed || null } as any); + toast.success('Profile updated'); + } catch { + toast.error('Failed to update profile'); + } + }; + + const handleSocialToggle = async (field: string, checked: boolean, setter: (v: boolean) => void) => { + const previous = (settings as any)?.[field]; + setter(checked); + try { + await updateSettings({ [field]: checked } as any); + } catch { + setter(previous); + toast.error('Failed to update setting'); + } + }; + const handleAutoLockMinutesSave = async () => { const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes; const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw)); @@ -363,6 +442,75 @@ export default function SettingsPage() { onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }} />
+
+
+ + setSettingsPhone(e.target.value)} + onBlur={() => handleSettingsFieldSave('phone', settingsPhone)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }} + maxLength={50} + /> +
+
+ + setSettingsMobile(e.target.value)} + onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }} + maxLength={50} + /> +
+
+
+ + setSettingsAddress(e.target.value)} + onBlur={() => handleSettingsFieldSave('address', settingsAddress)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }} + maxLength={2000} + /> +
+
+
+ + setSettingsCompany(e.target.value)} + onBlur={() => handleSettingsFieldSave('company', settingsCompany)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }} + maxLength={255} + /> +
+
+ + setSettingsJobTitle(e.target.value)} + onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }} + maxLength={255} + /> +
+
@@ -586,9 +734,88 @@ export default function SettingsPage() {
- {/* ── Right column: Security, Authentication, Integrations ── */} + {/* ── Right column: Social, Security, Authentication, Integrations ── */}
+ {/* Social */} + + +
+
+
+
+ Social + Manage your Umbra identity and connections +
+
+
+ +
+ +
+ { 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' : ''} + /> + +
+ {umbralNameError ? ( +

{umbralNameError}

+ ) : ( +

+ How other Umbra users find you +

+ )} +
+
+
+ +

+ Allow other users to find and connect with you +

+
+ handleSocialToggle('accept_connections', checked, setAcceptConnections)} + /> +
+
+

+ Sharing Defaults +

+
+ {[ + { 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 }) => ( +
+ + handleSocialToggle(field, checked, setter)} + /> +
+ ))} +
+
+
+
+ {/* Security (auto-lock) */} diff --git a/frontend/src/components/shared/CategoryFilterBar.tsx b/frontend/src/components/shared/CategoryFilterBar.tsx index 892a926..4739557 100644 --- a/frontend/src/components/shared/CategoryFilterBar.tsx +++ b/frontend/src/components/shared/CategoryFilterBar.tsx @@ -18,6 +18,12 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +interface ExtraPinnedFilter { + label: string; + isActive: boolean; + onToggle: () => void; +} + interface CategoryFilterBarProps { activeFilters: string[]; pinnedLabel: string; @@ -30,6 +36,7 @@ interface CategoryFilterBarProps { onReorderCategories?: (order: string[]) => void; searchValue: string; onSearchChange: (val: string) => void; + extraPinnedFilters?: ExtraPinnedFilter[]; } const pillBase = @@ -116,6 +123,7 @@ export default function CategoryFilterBar({ onReorderCategories, searchValue, onSearchChange, + extraPinnedFilters = [], }: CategoryFilterBarProps) { const [otherOpen, setOtherOpen] = useState(false); const searchInputRef = useRef(null); @@ -169,6 +177,22 @@ export default function CategoryFilterBar({ + {/* Extra pinned filters (e.g. "Umbral") */} + {extraPinnedFilters.map((epf) => ( + + ))} + {/* Categories pill + expandable chips */} {categories.length > 0 && ( <> diff --git a/frontend/src/components/shared/EntityDetailPanel.tsx b/frontend/src/components/shared/EntityDetailPanel.tsx index 2a2781d..d0ac938 100644 --- a/frontend/src/components/shared/EntityDetailPanel.tsx +++ b/frontend/src/components/shared/EntityDetailPanel.tsx @@ -27,6 +27,7 @@ interface EntityDetailPanelProps { isFavourite?: boolean; onToggleFavourite?: () => void; favouriteLabel?: string; + extraActions?: (item: T) => React.ReactNode; } export function EntityDetailPanel({ @@ -42,6 +43,7 @@ export function EntityDetailPanel({ isFavourite, onToggleFavourite, favouriteLabel = 'favourite', + extraActions, }: EntityDetailPanelProps) { const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete); @@ -134,7 +136,10 @@ export function EntityDetailPanel({ {/* Footer */}
- {formatUpdatedAt(getUpdatedAt(item))} +
+ {formatUpdatedAt(getUpdatedAt(item))} + {extraActions?.(item)} +