diff --git a/.gitignore b/.gitignore index 057e874..2e544a0 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ app.*.map.json *.apk # Local Agent / IDE cache & scratch artifacts +.codex/ .agent/ .agents/ .agent-backup/ diff --git a/1.29 b/1.29 new file mode 100644 index 0000000..e69de29 diff --git a/backend/motorsignal_api/.env.example b/backend/motorsignal_api/.env.example new file mode 100644 index 0000000..c0c1440 --- /dev/null +++ b/backend/motorsignal_api/.env.example @@ -0,0 +1,18 @@ +MOTORSIGNAL_ENV=development +JWT_SECRET=replace-with-a-local-secret +ACCESS_TOKEN_MINUTES=15 +REFRESH_TOKEN_DAYS=30 +DATABASE_HOST=127.0.0.1 +DATABASE_PORT=5432 +DATABASE_NAME=motorsignal_dev +DATABASE_USER=motorsignal +DATABASE_PASSWORD=replace-me +WEB_BASE_URL=http://localhost:3000 + +# Optional email delivery. Without SMTP_HOST, token emails are not sent. +SMTP_HOST= +SMTP_PORT=587 +SMTP_FROM= +SMTP_STARTTLS=true +SMTP_USER= +SMTP_PASSWORD= diff --git a/backend/motorsignal_api/.gitignore b/backend/motorsignal_api/.gitignore new file mode 100644 index 0000000..480a10e --- /dev/null +++ b/backend/motorsignal_api/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.bad-* +.env +.env.* +!.env.example +venv/ +.venv/ +.python-packages/ +tests/__pycache__/ diff --git a/backend/motorsignal_api/README.md b/backend/motorsignal_api/README.md new file mode 100644 index 0000000..7989c9a --- /dev/null +++ b/backend/motorsignal_api/README.md @@ -0,0 +1,26 @@ +# MotorSignal API + +Local recovery of the deployed FastAPI application and Alembic baseline. + +## Provenance + +- Source copied from `/opt/motorsignal/current` on the production VPS on 2026-09-08. +- Current release resolved to `/opt/motorsignal/releases/20260906-01`. +- Production environment files, credentials, virtualenv files, and database data were not copied. +- `requirements.lock` was reconstructed from the running production virtualenv with `pip freeze`; parity against a fresh production freeze passed. + +## Current scope + +The recovered API contains health/readiness checks, registration, login, refresh, +logout, and current-user lookup. The recovered Alembic history contains one +revision creating `users` and `sessions`. + +Email verification, password reset, session management endpoints, roles/RBAC, +audit events, vehicles, rides, telemetry, entitlements, and admin endpoints are +not present in this baseline. + +## Local safety + +Do not point local commands at production. Use a development database and an +explicit local environment file. Never commit secrets or production database +credentials. diff --git a/backend/motorsignal_api/alembic.ini b/backend/motorsignal_api/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/backend/motorsignal_api/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/motorsignal_api/alembic/README b/backend/motorsignal_api/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/motorsignal_api/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/motorsignal_api/alembic/env.py b/backend/motorsignal_api/alembic/env.py new file mode 100644 index 0000000..69a35f0 --- /dev/null +++ b/backend/motorsignal_api/alembic/env.py @@ -0,0 +1,70 @@ +from logging.config import fileConfig +import os +import sys +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlalchemy.engine import URL + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app")) + +from models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +def get_url() -> str: + url = URL.create( + drivername="postgresql+psycopg", + username=os.environ["DATABASE_USER"], + password=os.environ["DATABASE_PASSWORD"], + host=os.environ.get("DATABASE_HOST", "127.0.0.1"), + port=int(os.environ.get("DATABASE_PORT", "5432")), + database=os.environ["DATABASE_NAME"], + ) + + return url.render_as_string(hide_password=False) + + +def run_migrations_offline() -> None: + context.configure( + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section) or {} + configuration["sqlalchemy.url"] = get_url() + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/motorsignal_api/alembic/script.py.mako b/backend/motorsignal_api/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/motorsignal_api/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/motorsignal_api/alembic/versions/3ab24c7ef78e_create_users_and_sessions.py b/backend/motorsignal_api/alembic/versions/3ab24c7ef78e_create_users_and_sessions.py new file mode 100644 index 0000000..7e6fd11 --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/3ab24c7ef78e_create_users_and_sessions.py @@ -0,0 +1,122 @@ +"""create users and sessions + +Revision ID: 3ab24c7ef78e +Revises: +Create Date: 2026-09-06 22:02:17.665258 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "3ab24c7ef78e" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + + op.create_table( + "users", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("password_hash", sa.String(length=512), nullable=False), + sa.Column( + "email_verified", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "is_active", + sa.Boolean(), + nullable=False, + server_default=sa.text("true"), + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_index( + op.f("ix_users_email"), + "users", + ["email"], + unique=True, + ) + + op.create_table( + "sessions", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("refresh_token_hash", sa.String(length=128), nullable=False), + sa.Column("device_name", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_index( + op.f("ix_sessions_refresh_token_hash"), + "sessions", + ["refresh_token_hash"], + unique=True, + ) + + op.create_index( + op.f("ix_sessions_user_id"), + "sessions", + ["user_id"], + unique=False, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + + op.drop_index( + op.f("ix_sessions_user_id"), + table_name="sessions", + ) + + op.drop_index( + op.f("ix_sessions_refresh_token_hash"), + table_name="sessions", + ) + + op.drop_table("sessions") + + op.drop_index( + op.f("ix_users_email"), + table_name="users", + ) + + op.drop_table("users") diff --git a/backend/motorsignal_api/alembic/versions/6d3f9b2e1a44_add_auth_controls_and_audit.py b/backend/motorsignal_api/alembic/versions/6d3f9b2e1a44_add_auth_controls_and_audit.py new file mode 100644 index 0000000..2b3ad13 --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/6d3f9b2e1a44_add_auth_controls_and_audit.py @@ -0,0 +1,69 @@ +"""add auth controls and audit + +Revision ID: 6d3f9b2e1a44 +Revises: 3ab24c7ef78e +Create Date: 2026-09-08 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "6d3f9b2e1a44" +down_revision: Union[str, Sequence[str], None] = "3ab24c7ef78e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("role", sa.String(length=32), nullable=False, server_default="user")) + op.create_index(op.f("ix_users_role"), "users", ["role"], unique=False) + + for table_name in ("email_verification_tokens", "password_reset_tokens"): + op.create_table( + table_name, + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("token_hash", sa.String(length=128), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f(f"ix_{table_name}_user_id"), table_name, ["user_id"], unique=False) + op.create_index(op.f(f"ix_{table_name}_token_hash"), table_name, ["token_hash"], unique=True) + + op.create_table( + "audit_events", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("event_type", sa.String(length=96), nullable=False), + sa.Column("actor_user_id", sa.UUID(), nullable=True), + sa.Column("target_user_id", sa.UUID(), nullable=True), + sa.Column("ip_address", sa.String(length=64), nullable=True), + sa.Column("user_agent", sa.String(length=512), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["target_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_audit_events_event_type"), "audit_events", ["event_type"], unique=False) + op.create_index(op.f("ix_audit_events_actor_user_id"), "audit_events", ["actor_user_id"], unique=False) + op.create_index(op.f("ix_audit_events_target_user_id"), "audit_events", ["target_user_id"], unique=False) + op.create_index(op.f("ix_audit_events_created_at"), "audit_events", ["created_at"], unique=False) + + +def downgrade() -> None: + for index_name in ("ix_audit_events_created_at", "ix_audit_events_target_user_id", "ix_audit_events_actor_user_id", "ix_audit_events_event_type"): + op.drop_index(index_name, table_name="audit_events") + op.drop_table("audit_events") + for table_name in ("password_reset_tokens", "email_verification_tokens"): + op.drop_index(op.f(f"ix_{table_name}_token_hash"), table_name=table_name) + op.drop_index(op.f(f"ix_{table_name}_user_id"), table_name=table_name) + op.drop_table(table_name) + op.drop_index(op.f("ix_users_role"), table_name="users") + op.drop_column("users", "role") diff --git a/backend/motorsignal_api/alembic/versions/8f1a4c2d7b90_add_admin_resources.py b/backend/motorsignal_api/alembic/versions/8f1a4c2d7b90_add_admin_resources.py new file mode 100644 index 0000000..749991c --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/8f1a4c2d7b90_add_admin_resources.py @@ -0,0 +1,63 @@ +"""add admin resource tables + +Revision ID: 8f1a4c2d7b90 +Revises: 6d3f9b2e1a44 +Create Date: 2026-09-08 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "8f1a4c2d7b90" +down_revision: Union[str, Sequence[str], None] = "6d3f9b2e1a44" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "vehicles", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("nickname", sa.String(length=120), nullable=False), + sa.Column("make", sa.String(length=120), nullable=False), + sa.Column("model", sa.String(length=120), nullable=False), + sa.Column("year", sa.Integer(), nullable=True), + sa.Column("vin", sa.String(length=17), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_vehicles_user_id"), "vehicles", ["user_id"], unique=False) + + op.create_table( + "rides", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("vehicle_id", sa.UUID(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("distance_miles", sa.Float(), nullable=True), + sa.Column("top_speed_mph", sa.Float(), nullable=True), + sa.Column("avg_speed_mph", sa.Float(), nullable=True), + sa.Column("status", sa.String(length=32), nullable=False, server_default="completed"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["vehicle_id"], ["vehicles.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rides_user_id"), "rides", ["user_id"], unique=False) + op.create_index(op.f("ix_rides_vehicle_id"), "rides", ["vehicle_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_rides_vehicle_id"), table_name="rides") + op.drop_index(op.f("ix_rides_user_id"), table_name="rides") + op.drop_table("rides") + op.drop_index(op.f("ix_vehicles_user_id"), table_name="vehicles") + op.drop_table("vehicles") diff --git a/backend/motorsignal_api/alembic/versions/b7c2d4e8f901_add_telemetry_points.py b/backend/motorsignal_api/alembic/versions/b7c2d4e8f901_add_telemetry_points.py new file mode 100644 index 0000000..cecb6df --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/b7c2d4e8f901_add_telemetry_points.py @@ -0,0 +1,44 @@ +"""add telemetry points + +Revision ID: b7c2d4e8f901 +Revises: 8f1a4c2d7b90 +Create Date: 2026-09-08 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "b7c2d4e8f901" +down_revision: Union[str, Sequence[str], None] = "8f1a4c2d7b90" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "telemetry_points", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("ride_id", sa.UUID(), nullable=False), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("latitude", sa.Float(), nullable=True), + sa.Column("longitude", sa.Float(), nullable=True), + sa.Column("speed_mph", sa.Float(), nullable=True), + sa.Column("lean_angle_deg", sa.Float(), nullable=True), + sa.Column("g_force", sa.Float(), nullable=True), + sa.Column("accel_g", sa.Float(), nullable=True), + sa.Column("brake_g", sa.Float(), nullable=True), + sa.ForeignKeyConstraint(["ride_id"], ["rides.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_telemetry_points_ride_id"), "telemetry_points", ["ride_id"], unique=False) + op.create_index(op.f("ix_telemetry_points_recorded_at"), "telemetry_points", ["recorded_at"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_telemetry_points_recorded_at"), table_name="telemetry_points") + op.drop_index(op.f("ix_telemetry_points_ride_id"), table_name="telemetry_points") + op.drop_table("telemetry_points") diff --git a/backend/motorsignal_api/alembic/versions/c8d3e5f90124_add_subscriptions.py b/backend/motorsignal_api/alembic/versions/c8d3e5f90124_add_subscriptions.py new file mode 100644 index 0000000..65c2c07 --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/c8d3e5f90124_add_subscriptions.py @@ -0,0 +1,55 @@ +"""add subscriptions and entitlements + +Revision ID: c8d3e5f90124 +Revises: b7c2d4e8f901 +Create Date: 2026-09-08 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c8d3e5f90124" +down_revision: Union[str, Sequence[str], None] = "b7c2d4e8f901" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "subscriptions", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("plan", sa.String(length=32), nullable=False, server_default="free"), + sa.Column("status", sa.String(length=32), nullable=False, server_default="active"), + sa.Column("current_period_start", sa.DateTime(timezone=True), nullable=True), + sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id"), + ) + op.create_index(op.f("ix_subscriptions_user_id"), "subscriptions", ["user_id"], unique=True) + op.create_table( + "entitlements", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("key", sa.String(length=96), nullable=False), + sa.Column("value", sa.JSON(), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_entitlements_user_id"), "entitlements", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_entitlements_user_id"), table_name="entitlements") + op.drop_table("entitlements") + op.drop_index(op.f("ix_subscriptions_user_id"), table_name="subscriptions") + op.drop_table("subscriptions") diff --git a/backend/motorsignal_api/alembic/versions/d9e4f6012a35_add_auth_rate_limits.py b/backend/motorsignal_api/alembic/versions/d9e4f6012a35_add_auth_rate_limits.py new file mode 100644 index 0000000..9c1fec1 --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/d9e4f6012a35_add_auth_rate_limits.py @@ -0,0 +1,32 @@ +"""add auth rate limits + +Revision ID: d9e4f6012a35 +Revises: c8d3e5f90124 +Create Date: 2026-09-08 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "d9e4f6012a35" +down_revision: Union[str, Sequence[str], None] = "c8d3e5f90124" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "auth_rate_limits", + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("window_started", sa.DateTime(timezone=True), nullable=False), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.PrimaryKeyConstraint("key"), + ) + + +def downgrade() -> None: + op.drop_table("auth_rate_limits") diff --git a/backend/motorsignal_api/app/auth.py b/backend/motorsignal_api/app/auth.py new file mode 100644 index 0000000..4a84ae1 --- /dev/null +++ b/backend/motorsignal_api/app/auth.py @@ -0,0 +1,89 @@ +import hashlib +import os +import secrets +import uuid +from datetime import datetime, timedelta, timezone + +import jwt +from pwdlib import PasswordHash + + +password_hash = PasswordHash.recommended() + +JWT_SECRET = os.environ["JWT_SECRET"] +ACCESS_TOKEN_MINUTES = int(os.environ.get("ACCESS_TOKEN_MINUTES", "15")) +REFRESH_TOKEN_DAYS = int(os.environ.get("REFRESH_TOKEN_DAYS", "30")) + +JWT_ALGORITHM = "HS256" + + +def normalize_email(email: str) -> str: + return email.strip().lower() + + +def hash_password(password: str) -> str: + return password_hash.hash(password) + + +def verify_password(password: str, hashed: str) -> bool: + return password_hash.verify(password, hashed) + + +def create_access_token( + user_id: uuid.UUID, + session_id: uuid.UUID, +) -> str: + now = datetime.now(timezone.utc) + + payload = { + "sub": str(user_id), + "sid": str(session_id), + "iat": now, + "exp": now + timedelta(minutes=ACCESS_TOKEN_MINUTES), + "type": "access", + } + + return jwt.encode( + payload, + JWT_SECRET, + algorithm=JWT_ALGORITHM, + ) + + +def create_refresh_token() -> tuple[str, str]: + raw = secrets.token_urlsafe(64) + + digest = hashlib.sha256( + raw.encode("utf-8") + ).hexdigest() + + return raw, digest + + +def hash_refresh_token(token: str) -> str: + return hashlib.sha256( + token.encode("utf-8") + ).hexdigest() + + +def create_one_time_token() -> tuple[str, str]: + raw = secrets.token_urlsafe(48) + return raw, hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def hash_one_time_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def refresh_expiry() -> datetime: + return datetime.now(timezone.utc) + timedelta( + days=REFRESH_TOKEN_DAYS + ) + + +def decode_access_token(token: str) -> dict: + return jwt.decode( + token, + JWT_SECRET, + algorithms=[JWT_ALGORITHM], + ) diff --git a/backend/motorsignal_api/app/db.py b/backend/motorsignal_api/app/db.py new file mode 100644 index 0000000..3e02735 --- /dev/null +++ b/backend/motorsignal_api/app/db.py @@ -0,0 +1,30 @@ +import os + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import URL + + +def database_url() -> URL: + return URL.create( + drivername="postgresql+psycopg", + username=os.environ["DATABASE_USER"], + password=os.environ["DATABASE_PASSWORD"], + host=os.environ.get("DATABASE_HOST", "127.0.0.1"), + port=int(os.environ.get("DATABASE_PORT", "5432")), + database=os.environ["DATABASE_NAME"], + ) + + +engine = create_engine( + database_url(), + pool_pre_ping=True, + pool_size=5, + max_overflow=5, + pool_timeout=10, +) + + +def check_database() -> bool: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + return True diff --git a/backend/motorsignal_api/app/main.py b/backend/motorsignal_api/app/main.py new file mode 100644 index 0000000..80e2d3b --- /dev/null +++ b/backend/motorsignal_api/app/main.py @@ -0,0 +1,1063 @@ +import uuid +import logging +import os +import smtplib +from datetime import datetime, timedelta, timezone +from email.message import EmailMessage + +from email_validator import EmailNotValidError, validate_email +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status +from pydantic import BaseModel, Field +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session as DbSession + +from auth import ( + create_access_token, + create_refresh_token, + create_one_time_token, + decode_access_token, + hash_password, + hash_refresh_token, + hash_one_time_token, + normalize_email, + refresh_expiry, + verify_password, +) +from db import check_database, engine +from models import AuditEvent, AuthRateLimit, EmailVerificationToken, Entitlement, PasswordResetToken, Ride, Session, Subscription, TelemetryPoint, User, Vehicle + + +logger = logging.getLogger(__name__) + + +app = FastAPI( + title="MotorSignal API", + version="1.0.0", + docs_url=None, + redoc_url=None, + openapi_url=None, +) + + +class RegisterRequest(BaseModel): + email: str + password: str = Field(min_length=12, max_length=128) + device_name: str | None = Field(default=None, max_length=255) + + +class LoginRequest(BaseModel): + email: str + password: str + device_name: str | None = Field(default=None, max_length=255) + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class EmailRequest(BaseModel): + email: str + + +class TokenRequest(BaseModel): + token: str = Field(min_length=20, max_length=256) + + +class PasswordResetRequest(TokenRequest): + password: str = Field(min_length=12, max_length=128) + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class SessionResponse(BaseModel): + id: uuid.UUID + device_name: str | None + created_at: datetime + expires_at: datetime + last_used_at: datetime | None + current: bool + + +class AdminUserCreate(BaseModel): + email: str + password: str = Field(min_length=12, max_length=128) + role: str = "user" + + +class AdminUserUpdate(BaseModel): + email: str | None = None + role: str | None = None + is_active: bool | None = None + email_verified: bool | None = None + + +class VehicleCreate(BaseModel): + user_id: uuid.UUID + nickname: str = Field(min_length=1, max_length=120) + make: str = Field(min_length=1, max_length=120) + model: str = Field(min_length=1, max_length=120) + year: int | None = Field(default=None, ge=1885, le=2100) + vin: str | None = Field(default=None, min_length=11, max_length=17) + + +class VehicleUpdate(BaseModel): + nickname: str | None = Field(default=None, min_length=1, max_length=120) + make: str | None = Field(default=None, min_length=1, max_length=120) + model: str | None = Field(default=None, min_length=1, max_length=120) + year: int | None = Field(default=None, ge=1885, le=2100) + vin: str | None = Field(default=None, min_length=11, max_length=17) + + +class RideCreate(BaseModel): + user_id: uuid.UUID + vehicle_id: uuid.UUID | None = None + started_at: datetime + ended_at: datetime | None = None + distance_miles: float | None = Field(default=None, ge=0) + top_speed_mph: float | None = Field(default=None, ge=0) + avg_speed_mph: float | None = Field(default=None, ge=0) + status: str = Field(default="completed", min_length=1, max_length=32) + + +class RideUpdate(BaseModel): + vehicle_id: uuid.UUID | None = None + ended_at: datetime | None = None + distance_miles: float | None = Field(default=None, ge=0) + top_speed_mph: float | None = Field(default=None, ge=0) + avg_speed_mph: float | None = Field(default=None, ge=0) + status: str | None = Field(default=None, min_length=1, max_length=32) + + +class UserVehicleCreate(BaseModel): + nickname: str = Field(min_length=1, max_length=120) + make: str = Field(min_length=1, max_length=120) + model: str = Field(min_length=1, max_length=120) + year: int | None = Field(default=None, ge=1885, le=2100) + vin: str | None = Field(default=None, min_length=11, max_length=17) + + +class UserRideCreate(BaseModel): + vehicle_id: uuid.UUID | None = None + started_at: datetime + ended_at: datetime | None = None + distance_miles: float | None = Field(default=None, ge=0) + top_speed_mph: float | None = Field(default=None, ge=0) + avg_speed_mph: float | None = Field(default=None, ge=0) + status: str = Field(default="completed", min_length=1, max_length=32) + + +class TelemetryPointInput(BaseModel): + recorded_at: datetime + latitude: float | None = Field(default=None, ge=-90, le=90) + longitude: float | None = Field(default=None, ge=-180, le=180) + speed_mph: float | None = Field(default=None, ge=0) + lean_angle_deg: float | None = Field(default=None, ge=-90, le=90) + g_force: float | None = Field(default=None, ge=0) + accel_g: float | None = Field(default=None, ge=-10, le=10) + brake_g: float | None = Field(default=None, ge=-10, le=10) + + +class TelemetryBatch(BaseModel): + points: list[TelemetryPointInput] = Field(min_length=1, max_length=1000) + + +class SubscriptionUpdate(BaseModel): + plan: str = Field(min_length=1, max_length=32) + status: str = Field(min_length=1, max_length=32) + current_period_start: datetime | None = None + current_period_end: datetime | None = None + + +class EntitlementCreate(BaseModel): + key: str = Field(min_length=1, max_length=96) + value: dict | None = None + expires_at: datetime | None = None + + +def get_db(): + db = DbSession(engine) + + try: + yield db + finally: + db.close() + + +def enforce_auth_rate_limit(request: Request, db: DbSession, action: str, limit: int = 10, window_seconds: int = 900) -> None: + client = request.client.host if request.client else "unknown" + key = f"{action}:{client}" + now = datetime.now(timezone.utc) + bucket = db.get(AuthRateLimit, key, with_for_update=True) + if bucket is None: + db.add(AuthRateLimit(key=key, window_started=now, attempts=1)) + db.commit() + return + elapsed = (now - bucket.window_started).total_seconds() + if elapsed >= window_seconds: + bucket.window_started = now + bucket.attempts = 1 + elif bucket.attempts >= limit: + retry_after = max(1, int(window_seconds - elapsed)) + raise HTTPException(status_code=429, detail="Too many authentication attempts", headers={"Retry-After": str(retry_after)}) + else: + bucket.attempts += 1 + db.commit() + + +def extract_bearer_token(authorization: str | None) -> str: + if not authorization: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authorization token", + ) + + scheme, _, token = authorization.partition(" ") + + if scheme.lower() != "bearer" or not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization header", + ) + + return token + + +def get_request_context(request: Request) -> tuple[str | None, str | None]: + return request.client.host if request.client else None, request.headers.get("user-agent") + + +def audit( + db: DbSession, + event_type: str, + actor_user_id: uuid.UUID | None = None, + target_user_id: uuid.UUID | None = None, + request: Request | None = None, + metadata: dict | None = None, +) -> None: + ip_address = request.client.host if request and request.client else None + user_agent = request.headers.get("user-agent")[:512] if request and request.headers.get("user-agent") else None + db.add(AuditEvent(event_type=event_type, actor_user_id=actor_user_id, target_user_id=target_user_id, ip_address=ip_address, user_agent=user_agent, metadata_json=metadata)) + + +def send_email(recipient: str, subject: str, body: str) -> None: + host = os.getenv("SMTP_HOST") + if not host: + return + try: + message = EmailMessage() + message["From"] = os.environ["SMTP_FROM"] + message["To"] = recipient + message["Subject"] = subject + message.set_content(body) + port = int(os.getenv("SMTP_PORT", "587")) + with smtplib.SMTP(host, port, timeout=10) as smtp: + if os.getenv("SMTP_STARTTLS", "true").lower() == "true": + smtp.starttls() + if os.getenv("SMTP_USER"): + smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"]) + smtp.send_message(message) + except (KeyError, OSError, smtplib.SMTPException): + logger.exception("MotorSignal email delivery failed") + + +def get_current_identity( + authorization: str | None = Header(default=None), + db: DbSession = Depends(get_db), +) -> tuple[User, Session]: + token = extract_bearer_token(authorization) + + try: + payload = decode_access_token(token) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired access token", + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid access token", + ) + + try: + user_id = uuid.UUID(payload["sub"]) + session_id = uuid.UUID(payload["sid"]) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid access token", + ) + + session = db.get(Session, session_id) + + if ( + session is None + or session.user_id != user_id + or session.revoked_at is not None + or session.expires_at <= datetime.now(timezone.utc) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Session is no longer valid", + ) + + user = db.get(User, user_id) + + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User is unavailable", + ) + + return user, session + + +def get_current_user(identity: tuple[User, Session] = Depends(get_current_identity)) -> User: + return identity[0] + + +def require_roles(*roles: str): + def dependency(user: User = Depends(get_current_user)) -> User: + if user.role not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return user + return dependency + + +@app.get("/api/v1/health") +def health(): + return { + "status": "ok", + "service": "motorsignal-api", + } + + +@app.get("/api/v1/ready") +def readiness(): + try: + check_database() + + return { + "status": "ready", + "database": "ok", + } + except Exception: + raise HTTPException( + status_code=503, + detail="Service unavailable", + ) + + +@app.post( + "/api/v1/auth/register", + response_model=TokenResponse, + status_code=201, +) +def register( + request: RegisterRequest, + http_request: Request, + db: DbSession = Depends(get_db), +): + enforce_auth_rate_limit(http_request, db, "register") + try: + validated = validate_email( + request.email, + check_deliverability=False, + ) + email = normalize_email(validated.normalized) + + except EmailNotValidError: + raise HTTPException( + status_code=400, + detail="Invalid email address", + ) + + existing = db.scalar( + select(User).where(User.email == email) + ) + + if existing is not None: + raise HTTPException( + status_code=409, + detail="Account already exists", + ) + + user = User( + email=email, + password_hash=hash_password(request.password), + ) + + db.add(user) + db.flush() + + raw_refresh, refresh_hash = create_refresh_token() + + session = Session( + user_id=user.id, + refresh_token_hash=refresh_hash, + device_name=request.device_name, + expires_at=refresh_expiry(), + ) + + db.add(session) + verification_raw, verification_hash = create_one_time_token() + db.add(EmailVerificationToken(user_id=user.id, token_hash=verification_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24))) + audit(db, "auth.register", actor_user_id=user.id, target_user_id=user.id) + db.commit() + + web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app") + send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={verification_raw}") + + return TokenResponse( + access_token=create_access_token(user.id, session.id), + refresh_token=raw_refresh, + ) + + +@app.post( + "/api/v1/auth/login", + response_model=TokenResponse, +) +def login( + request: LoginRequest, + http_request: Request, + db: DbSession = Depends(get_db), +): + enforce_auth_rate_limit(http_request, db, "login") + email = normalize_email(request.email) + + user = db.scalar( + select(User).where(User.email == email) + ) + + if user is None or not verify_password( + request.password, + user.password_hash, + ): + raise HTTPException( + status_code=401, + detail="Invalid email or password", + ) + + if not user.is_active: + raise HTTPException( + status_code=403, + detail="Account disabled", + ) + + raw_refresh, refresh_hash = create_refresh_token() + + session = Session( + user_id=user.id, + refresh_token_hash=refresh_hash, + device_name=request.device_name, + expires_at=refresh_expiry(), + ) + + db.add(session) + audit(db, "auth.login", actor_user_id=user.id, target_user_id=user.id) + db.commit() + + return TokenResponse( + access_token=create_access_token(user.id, session.id), + refresh_token=raw_refresh, + ) + + +@app.post( + "/api/v1/auth/refresh", + response_model=TokenResponse, +) +def refresh( + request: RefreshRequest, + db: DbSession = Depends(get_db), +): + old_hash = hash_refresh_token(request.refresh_token) + + session = db.scalar( + select(Session).where( + Session.refresh_token_hash == old_hash + ) + ) + + now = datetime.now(timezone.utc) + + if ( + session is None + or session.revoked_at is not None + or session.expires_at <= now + ): + raise HTTPException( + status_code=401, + detail="Invalid refresh token", + ) + + user = db.get(User, session.user_id) + + if user is None or not user.is_active: + raise HTTPException( + status_code=401, + detail="Invalid refresh token", + ) + + new_raw, new_hash = create_refresh_token() + + session.refresh_token_hash = new_hash + session.last_used_at = now + session.expires_at = refresh_expiry() + + db.commit() + + return TokenResponse( + access_token=create_access_token(user.id, session.id), + refresh_token=new_raw, + ) + + +@app.post("/api/v1/auth/logout", status_code=204) +def logout( + request: RefreshRequest, + db: DbSession = Depends(get_db), +): + refresh_hash = hash_refresh_token(request.refresh_token) + + session = db.scalar( + select(Session).where( + Session.refresh_token_hash == refresh_hash + ) + ) + + if session is not None and session.revoked_at is None: + session.revoked_at = datetime.now(timezone.utc) + audit(db, "auth.logout", actor_user_id=session.user_id, target_user_id=session.user_id) + db.commit() + + return None + + +@app.get("/api/v1/me") +def me(user: User = Depends(get_current_user)): + return { + "id": str(user.id), + "email": user.email, + "email_verified": user.email_verified, + "created_at": user.created_at, + } + + +@app.post("/api/v1/auth/verify-email/request", status_code=202) +def request_email_verification(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)): + enforce_auth_rate_limit(http_request, db, "verify-email-request") + email = normalize_email(payload.email) + user = db.scalar(select(User).where(User.email == email)) + if user is not None and not user.email_verified and user.is_active: + raw, token_hash = create_one_time_token() + db.add(EmailVerificationToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24))) + audit(db, "auth.email_verification_requested", target_user_id=user.id) + db.commit() + web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app") + send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={raw}") + return {"detail": "If the account can be verified, instructions have been sent."} + + +@app.post("/api/v1/auth/verify-email", status_code=204) +def verify_email(request: TokenRequest, http_request: Request, db: DbSession = Depends(get_db)): + enforce_auth_rate_limit(http_request, db, "verify-email") + token = db.scalar(select(EmailVerificationToken).where(EmailVerificationToken.token_hash == hash_one_time_token(request.token))) + now = datetime.now(timezone.utc) + if token is None or token.used_at is not None or token.expires_at <= now: + raise HTTPException(status_code=400, detail="Invalid or expired verification token") + user = db.get(User, token.user_id) + if user is None: + raise HTTPException(status_code=400, detail="Invalid verification token") + user.email_verified = True + token.used_at = now + audit(db, "auth.email_verified", actor_user_id=user.id, target_user_id=user.id) + db.commit() + return None + + +@app.post("/api/v1/auth/password-reset/request", status_code=202) +def request_password_reset(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)): + enforce_auth_rate_limit(http_request, db, "password-reset-request") + email = normalize_email(payload.email) + user = db.scalar(select(User).where(User.email == email)) + if user is not None and user.is_active: + raw, token_hash = create_one_time_token() + db.add(PasswordResetToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=1))) + audit(db, "auth.password_reset_requested", target_user_id=user.id) + db.commit() + web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app") + send_email(email, "Reset your MotorSignal password", f"Reset your password: {web_base}/forgot-password?token={raw}") + return {"detail": "If the account exists, reset instructions have been sent."} + + +@app.post("/api/v1/auth/password-reset", status_code=204) +def reset_password(request: PasswordResetRequest, http_request: Request, db: DbSession = Depends(get_db)): + enforce_auth_rate_limit(http_request, db, "password-reset") + token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_one_time_token(request.token))) + now = datetime.now(timezone.utc) + if token is None or token.used_at is not None or token.expires_at <= now: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + user = db.get(User, token.user_id) + if user is None or not user.is_active: + raise HTTPException(status_code=400, detail="Invalid reset token") + user.password_hash = hash_password(request.password) + token.used_at = now + db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False) + audit(db, "auth.password_reset_completed", actor_user_id=user.id, target_user_id=user.id) + db.commit() + return None + + +@app.get("/api/v1/auth/sessions", response_model=list[SessionResponse]) +def list_sessions(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)): + user, current = identity + sessions = db.scalars(select(Session).where(Session.user_id == user.id, Session.revoked_at.is_(None)).order_by(Session.created_at.desc())).all() + return [SessionResponse(id=item.id, device_name=item.device_name, created_at=item.created_at, expires_at=item.expires_at, last_used_at=item.last_used_at, current=item.id == current.id) for item in sessions] + + +@app.delete("/api/v1/auth/sessions/{session_id}", status_code=204) +def revoke_session(session_id: uuid.UUID, identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)): + user, _ = identity + session = db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id)) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + if session.revoked_at is None: + session.revoked_at = datetime.now(timezone.utc) + audit(db, "auth.session_revoked", actor_user_id=user.id, target_user_id=user.id, metadata={"session_id": str(session.id)}) + db.commit() + return None + + +@app.post("/api/v1/auth/logout-all", status_code=204) +def logout_all(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)): + user, _ = identity + now = datetime.now(timezone.utc) + db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False) + audit(db, "auth.logout_all", actor_user_id=user.id, target_user_id=user.id) + db.commit() + return None + + +@app.get("/api/v1/admin/audit-events") +def list_audit_events(user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + events = db.scalars(select(AuditEvent).order_by(AuditEvent.created_at.desc()).limit(100)).all() + return [{"id": str(event.id), "event_type": event.event_type, "actor_user_id": str(event.actor_user_id) if event.actor_user_id else None, "target_user_id": str(event.target_user_id) if event.target_user_id else None, "created_at": event.created_at, "metadata": event.metadata_json} for event in events] + + +def user_payload(user: User) -> dict: + return {"id": str(user.id), "email": user.email, "role": user.role, "is_active": user.is_active, "email_verified": user.email_verified, "created_at": user.created_at} + + +def vehicle_payload(vehicle: Vehicle) -> dict: + return {"id": str(vehicle.id), "user_id": str(vehicle.user_id), "nickname": vehicle.nickname, "make": vehicle.make, "model": vehicle.model, "year": vehicle.year, "vin": vehicle.vin, "created_at": vehicle.created_at, "updated_at": vehicle.updated_at} + + +def ride_payload(ride: Ride) -> dict: + return {"id": str(ride.id), "user_id": str(ride.user_id), "vehicle_id": str(ride.vehicle_id) if ride.vehicle_id else None, "started_at": ride.started_at, "ended_at": ride.ended_at, "distance_miles": ride.distance_miles, "top_speed_mph": ride.top_speed_mph, "avg_speed_mph": ride.avg_speed_mph, "status": ride.status, "created_at": ride.created_at} + + +def validate_role(role: str) -> str: + if role not in {"user", "support", "admin"}: + raise HTTPException(status_code=400, detail="Invalid role") + return role + + +@app.get("/api/v1/admin/users") +def list_users(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), search: str | None = None, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + query = select(User) + count_query = select(func.count()).select_from(User) + if search: + pattern = f"%{search.strip().lower()}%" + condition = or_(func.lower(User.email).like(pattern), User.role == search.strip().lower()) + query = query.where(condition) + count_query = count_query.where(condition) + total = db.scalar(count_query) or 0 + users = db.scalars(query.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size)).all() + return {"items": [user_payload(item) for item in users], "page": page, "page_size": page_size, "total": total} + + +@app.post("/api/v1/admin/users", status_code=201) +def create_user(request: AdminUserCreate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + email = normalize_email(request.email) + try: + email = normalize_email(validate_email(email, check_deliverability=False).normalized) + except EmailNotValidError: + raise HTTPException(status_code=400, detail="Invalid email address") + if db.scalar(select(User).where(User.email == email)) is not None: + raise HTTPException(status_code=409, detail="Account already exists") + user = User(email=email, password_hash=hash_password(request.password), role=validate_role(request.role)) + db.add(user) + db.flush() + audit(db, "admin.user_created", actor_user_id=actor.id, target_user_id=user.id) + db.commit() + db.refresh(user) + return user_payload(user) + + +@app.get("/api/v1/admin/users/{user_id}") +def get_user(user_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + target = db.get(User, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + return user_payload(target) + + +@app.patch("/api/v1/admin/users/{user_id}") +def update_user(user_id: uuid.UUID, request: AdminUserUpdate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + target = db.get(User, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + changes = request.model_dump(exclude_unset=True) + if "email" in changes: + try: + changes["email"] = normalize_email(validate_email(changes["email"], check_deliverability=False).normalized) + except EmailNotValidError: + raise HTTPException(status_code=400, detail="Invalid email address") + duplicate = db.scalar(select(User).where(User.email == changes["email"], User.id != target.id)) + if duplicate is not None: + raise HTTPException(status_code=409, detail="Account already exists") + if "role" in changes: + changes["role"] = validate_role(changes["role"]) + for field, value in changes.items(): + setattr(target, field, value) + audit(db, "admin.user_updated", actor_user_id=actor.id, target_user_id=target.id, metadata={"fields": list(changes)}) + db.commit() + db.refresh(target) + return user_payload(target) + + +@app.delete("/api/v1/admin/users/{user_id}", status_code=204) +def deactivate_user(user_id: uuid.UUID, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + target = db.get(User, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + target.is_active = False + now = datetime.now(timezone.utc) + db.query(Session).filter(Session.user_id == target.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False) + audit(db, "admin.user_deactivated", actor_user_id=actor.id, target_user_id=target.id) + db.commit() + return None + + +@app.get("/api/v1/admin/vehicles") +def list_vehicles(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user_id: uuid.UUID | None = None, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + query = select(Vehicle) + count_query = select(func.count()).select_from(Vehicle) + if user_id: + query = query.where(Vehicle.user_id == user_id) + count_query = count_query.where(Vehicle.user_id == user_id) + total = db.scalar(count_query) or 0 + vehicles = db.scalars(query.order_by(Vehicle.created_at.desc()).offset((page - 1) * page_size).limit(page_size)).all() + return {"items": [vehicle_payload(item) for item in vehicles], "page": page, "page_size": page_size, "total": total} + + +@app.post("/api/v1/admin/vehicles", status_code=201) +def create_vehicle(request: VehicleCreate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + if db.get(User, request.user_id) is None: + raise HTTPException(status_code=404, detail="User not found") + vehicle = Vehicle(**request.model_dump()) + db.add(vehicle) + db.flush() + audit(db, "admin.vehicle_created", actor_user_id=actor.id, target_user_id=vehicle.user_id, metadata={"vehicle_id": str(vehicle.id)}) + db.commit() + db.refresh(vehicle) + return vehicle_payload(vehicle) + + +@app.get("/api/v1/admin/vehicles/{vehicle_id}") +def get_vehicle(vehicle_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + vehicle = db.get(Vehicle, vehicle_id) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + return vehicle_payload(vehicle) + + +@app.patch("/api/v1/admin/vehicles/{vehicle_id}") +def update_vehicle(vehicle_id: uuid.UUID, request: VehicleUpdate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + vehicle = db.get(Vehicle, vehicle_id) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + changes = request.model_dump(exclude_unset=True) + for field, value in changes.items(): + setattr(vehicle, field, value) + audit(db, "admin.vehicle_updated", actor_user_id=actor.id, target_user_id=vehicle.user_id, metadata={"vehicle_id": str(vehicle.id), "fields": list(changes)}) + db.commit() + db.refresh(vehicle) + return vehicle_payload(vehicle) + + +@app.delete("/api/v1/admin/vehicles/{vehicle_id}", status_code=204) +def delete_vehicle(vehicle_id: uuid.UUID, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + vehicle = db.get(Vehicle, vehicle_id) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + audit(db, "admin.vehicle_deleted", actor_user_id=actor.id, target_user_id=vehicle.user_id, metadata={"vehicle_id": str(vehicle.id)}) + db.delete(vehicle) + db.commit() + return None + + +@app.get("/api/v1/admin/rides") +def list_rides(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user_id: uuid.UUID | None = None, vehicle_id: uuid.UUID | None = None, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + query = select(Ride) + count_query = select(func.count()).select_from(Ride) + if user_id: + query = query.where(Ride.user_id == user_id) + count_query = count_query.where(Ride.user_id == user_id) + if vehicle_id: + query = query.where(Ride.vehicle_id == vehicle_id) + count_query = count_query.where(Ride.vehicle_id == vehicle_id) + total = db.scalar(count_query) or 0 + rides = db.scalars(query.order_by(Ride.started_at.desc()).offset((page - 1) * page_size).limit(page_size)).all() + return {"items": [ride_payload(item) for item in rides], "page": page, "page_size": page_size, "total": total} + + +@app.post("/api/v1/admin/rides", status_code=201) +def create_ride(request: RideCreate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + if db.get(User, request.user_id) is None: + raise HTTPException(status_code=404, detail="User not found") + if request.vehicle_id is not None: + vehicle = db.get(Vehicle, request.vehicle_id) + if vehicle is None or vehicle.user_id != request.user_id: + raise HTTPException(status_code=400, detail="Vehicle does not belong to user") + ride = Ride(**request.model_dump()) + db.add(ride) + db.flush() + audit(db, "admin.ride_created", actor_user_id=actor.id, target_user_id=ride.user_id, metadata={"ride_id": str(ride.id)}) + db.commit() + db.refresh(ride) + return ride_payload(ride) + + +@app.get("/api/v1/admin/rides/{ride_id}") +def get_ride(ride_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + ride = db.get(Ride, ride_id) + if ride is None: + raise HTTPException(status_code=404, detail="Ride not found") + return ride_payload(ride) + + +@app.patch("/api/v1/admin/rides/{ride_id}") +def update_ride(ride_id: uuid.UUID, request: RideUpdate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + ride = db.get(Ride, ride_id) + if ride is None: + raise HTTPException(status_code=404, detail="Ride not found") + changes = request.model_dump(exclude_unset=True) + if "vehicle_id" in changes and changes["vehicle_id"] is not None: + vehicle = db.get(Vehicle, changes["vehicle_id"]) + if vehicle is None or vehicle.user_id != ride.user_id: + raise HTTPException(status_code=400, detail="Vehicle does not belong to user") + for field, value in changes.items(): + setattr(ride, field, value) + audit(db, "admin.ride_updated", actor_user_id=actor.id, target_user_id=ride.user_id, metadata={"ride_id": str(ride.id), "fields": list(changes)}) + db.commit() + db.refresh(ride) + return ride_payload(ride) + + +@app.delete("/api/v1/admin/rides/{ride_id}", status_code=204) +def archive_ride(ride_id: uuid.UUID, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + ride = db.get(Ride, ride_id) + if ride is None: + raise HTTPException(status_code=404, detail="Ride not found") + ride.status = "archived" + audit(db, "admin.ride_archived", actor_user_id=actor.id, target_user_id=ride.user_id, metadata={"ride_id": str(ride.id)}) + db.commit() + return None + + +@app.get("/api/v1/vehicles") +def list_my_vehicles(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + vehicles = db.scalars(select(Vehicle).where(Vehicle.user_id == user.id).order_by(Vehicle.created_at.desc())).all() + return [vehicle_payload(vehicle) for vehicle in vehicles] + + +@app.post("/api/v1/vehicles", status_code=201) +def create_my_vehicle(request: UserVehicleCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + vehicle = Vehicle(user_id=user.id, **request.model_dump()) + db.add(vehicle) + db.flush() + audit(db, "vehicle.created", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)}) + db.commit() + db.refresh(vehicle) + return vehicle_payload(vehicle) + + +@app.get("/api/v1/vehicles/{vehicle_id}") +def get_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id)) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + return vehicle_payload(vehicle) + + +@app.patch("/api/v1/vehicles/{vehicle_id}") +def update_my_vehicle(vehicle_id: uuid.UUID, request: VehicleUpdate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id)) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + changes = request.model_dump(exclude_unset=True) + for field, value in changes.items(): + setattr(vehicle, field, value) + audit(db, "vehicle.updated", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id), "fields": list(changes)}) + db.commit() + db.refresh(vehicle) + return vehicle_payload(vehicle) + + +@app.delete("/api/v1/vehicles/{vehicle_id}", status_code=204) +def delete_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id)) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + audit(db, "vehicle.deleted", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)}) + db.delete(vehicle) + db.commit() + return None + + +@app.get("/api/v1/rides") +def list_my_rides(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + query = select(Ride).where(Ride.user_id == user.id) + total = db.scalar(select(func.count()).select_from(Ride).where(Ride.user_id == user.id)) or 0 + rides = db.scalars(query.order_by(Ride.started_at.desc()).offset((page - 1) * page_size).limit(page_size)).all() + return {"items": [ride_payload(ride) for ride in rides], "page": page, "page_size": page_size, "total": total} + + +@app.post("/api/v1/rides", status_code=201) +def create_my_ride(request: UserRideCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + if request.vehicle_id is not None: + vehicle = db.scalar(select(Vehicle).where(Vehicle.id == request.vehicle_id, Vehicle.user_id == user.id)) + if vehicle is None: + raise HTTPException(status_code=400, detail="Vehicle not found") + ride = Ride(user_id=user.id, **request.model_dump()) + db.add(ride) + db.flush() + audit(db, "ride.created", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride.id)}) + db.commit() + db.refresh(ride) + return ride_payload(ride) + + +@app.get("/api/v1/rides/{ride_id}") +def get_my_ride(ride_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user.id)) + if ride is None: + raise HTTPException(status_code=404, detail="Ride not found") + return ride_payload(ride) + + +def telemetry_payload(point: TelemetryPoint) -> dict: + return {"id": str(point.id), "ride_id": str(point.ride_id), "recorded_at": point.recorded_at, "latitude": point.latitude, "longitude": point.longitude, "speed_mph": point.speed_mph, "lean_angle_deg": point.lean_angle_deg, "g_force": point.g_force, "accel_g": point.accel_g, "brake_g": point.brake_g} + + +def get_owned_ride(ride_id: uuid.UUID, user_id: uuid.UUID, db: DbSession) -> Ride: + ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user_id)) + if ride is None: + raise HTTPException(status_code=404, detail="Ride not found") + return ride + + +@app.post("/api/v1/rides/{ride_id}/telemetry", status_code=201) +def ingest_telemetry(ride_id: uuid.UUID, request: TelemetryBatch, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + get_owned_ride(ride_id, user.id, db) + points = [TelemetryPoint(ride_id=ride_id, **point.model_dump()) for point in request.points] + db.add_all(points) + audit(db, "telemetry.ingested", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride_id), "point_count": len(points)}) + db.commit() + return {"accepted": len(points)} + + +@app.get("/api/v1/rides/{ride_id}/telemetry") +def list_my_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + get_owned_ride(ride_id, user.id, db) + points = db.scalars(select(TelemetryPoint).where(TelemetryPoint.ride_id == ride_id).order_by(TelemetryPoint.recorded_at.asc()).limit(limit)).all() + return {"items": [telemetry_payload(point) for point in points], "count": len(points)} + + +@app.get("/api/v1/admin/rides/{ride_id}/telemetry") +def list_admin_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + if db.get(Ride, ride_id) is None: + raise HTTPException(status_code=404, detail="Ride not found") + points = db.scalars(select(TelemetryPoint).where(TelemetryPoint.ride_id == ride_id).order_by(TelemetryPoint.recorded_at.asc()).limit(limit)).all() + return {"items": [telemetry_payload(point) for point in points], "count": len(points)} + + +def subscription_payload(subscription: Subscription) -> dict: + return {"id": str(subscription.id), "user_id": str(subscription.user_id), "plan": subscription.plan, "status": subscription.status, "current_period_start": subscription.current_period_start, "current_period_end": subscription.current_period_end, "created_at": subscription.created_at, "updated_at": subscription.updated_at} + + +def entitlement_payload(entitlement: Entitlement) -> dict: + return {"id": str(entitlement.id), "user_id": str(entitlement.user_id), "key": entitlement.key, "value": entitlement.value, "expires_at": entitlement.expires_at, "created_at": entitlement.created_at} + + +def get_or_create_subscription(user_id: uuid.UUID, db: DbSession) -> Subscription: + subscription = db.scalar(select(Subscription).where(Subscription.user_id == user_id)) + if subscription is None: + subscription = Subscription(user_id=user_id, plan="free", status="active") + db.add(subscription) + db.flush() + return subscription + + +@app.get("/api/v1/me/subscription") +def get_my_subscription(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + subscription = get_or_create_subscription(user.id, db) + db.commit() + db.refresh(subscription) + return subscription_payload(subscription) + + +@app.get("/api/v1/me/entitlements") +def list_my_entitlements(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + entitlements = db.scalars(select(Entitlement).where(Entitlement.user_id == user.id).order_by(Entitlement.created_at.desc())).all() + return [entitlement_payload(item) for item in entitlements if item.expires_at is None or item.expires_at > datetime.now(timezone.utc)] + + +@app.get("/api/v1/admin/subscriptions") +def list_subscriptions(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + total = db.scalar(select(func.count()).select_from(Subscription)) or 0 + subscriptions = db.scalars(select(Subscription).order_by(Subscription.updated_at.desc()).offset((page - 1) * page_size).limit(page_size)).all() + return {"items": [subscription_payload(item) for item in subscriptions], "page": page, "page_size": page_size, "total": total} + + +@app.patch("/api/v1/admin/users/{user_id}/subscription") +def update_subscription(user_id: uuid.UUID, request: SubscriptionUpdate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + if db.get(User, user_id) is None: + raise HTTPException(status_code=404, detail="User not found") + subscription = get_or_create_subscription(user_id, db) + subscription.plan = request.plan + subscription.status = request.status + subscription.current_period_start = request.current_period_start + subscription.current_period_end = request.current_period_end + audit(db, "admin.subscription_updated", actor_user_id=actor.id, target_user_id=user_id, metadata={"plan": request.plan, "status": request.status}) + db.commit() + db.refresh(subscription) + return subscription_payload(subscription) + + +@app.get("/api/v1/admin/users/{user_id}/entitlements") +def list_entitlements(user_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + if db.get(User, user_id) is None: + raise HTTPException(status_code=404, detail="User not found") + return [entitlement_payload(item) for item in db.scalars(select(Entitlement).where(Entitlement.user_id == user_id).order_by(Entitlement.created_at.desc())).all()] + + +@app.post("/api/v1/admin/users/{user_id}/entitlements", status_code=201) +def create_entitlement(user_id: uuid.UUID, request: EntitlementCreate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)): + if db.get(User, user_id) is None: + raise HTTPException(status_code=404, detail="User not found") + entitlement = Entitlement(user_id=user_id, **request.model_dump()) + db.add(entitlement) + db.flush() + audit(db, "admin.entitlement_created", actor_user_id=actor.id, target_user_id=user_id, metadata={"key": entitlement.key}) + db.commit() + db.refresh(entitlement) + return entitlement_payload(entitlement) diff --git a/backend/motorsignal_api/app/models.py b/backend/motorsignal_api/app/models.py new file mode 100644 index 0000000..f627817 --- /dev/null +++ b/backend/motorsignal_api/app/models.py @@ -0,0 +1,233 @@ +import uuid +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + + email: Mapped[str] = mapped_column( + String(320), + unique=True, + nullable=False, + index=True, + ) + + password_hash: Mapped[str] = mapped_column( + String(512), + nullable=False, + ) + + email_verified: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + ) + + is_active: Mapped[bool] = mapped_column( + Boolean, + default=True, + nullable=False, + ) + + role: Mapped[str] = mapped_column( + String(32), + default="user", + nullable=False, + index=True, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + nullable=False, + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, + ) + + sessions: Mapped[list["Session"]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + ) + + +class Session(Base): + __tablename__ = "sessions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + refresh_token_hash: Mapped[str] = mapped_column( + String(128), + unique=True, + nullable=False, + index=True, + ) + + device_name: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + nullable=False, + ) + + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + + last_used_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + user: Mapped["User"] = relationship( + back_populates="sessions", + ) + + +class EmailVerificationToken(Base): + __tablename__ = "email_verification_tokens" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False) + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_tokens" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False) + + +class AuditEvent(Base): + __tablename__ = "audit_events" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + event_type: Mapped[str] = mapped_column(String(96), nullable=False, index=True) + actor_user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + target_user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True) + + +class Vehicle(Base): + __tablename__ = "vehicles" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + nickname: Mapped[str] = mapped_column(String(120), nullable=False) + make: Mapped[str] = mapped_column(String(120), nullable=False) + model: Mapped[str] = mapped_column(String(120), nullable=False) + year: Mapped[int | None] = mapped_column(nullable=True) + vin: Mapped[str | None] = mapped_column(String(17), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + +class Ride(Base): + __tablename__ = "rides" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + vehicle_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vehicles.id", ondelete="SET NULL"), nullable=True, index=True) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + distance_miles: Mapped[float | None] = mapped_column(nullable=True) + top_speed_mph: Mapped[float | None] = mapped_column(nullable=True) + avg_speed_mph: Mapped[float | None] = mapped_column(nullable=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="completed") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False) + + +class TelemetryPoint(Base): + __tablename__ = "telemetry_points" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + ride_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rides.id", ondelete="CASCADE"), nullable=False, index=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + latitude: Mapped[float | None] = mapped_column(nullable=True) + longitude: Mapped[float | None] = mapped_column(nullable=True) + speed_mph: Mapped[float | None] = mapped_column(nullable=True) + lean_angle_deg: Mapped[float | None] = mapped_column(nullable=True) + g_force: Mapped[float | None] = mapped_column(nullable=True) + accel_g: Mapped[float | None] = mapped_column(nullable=True) + brake_g: Mapped[float | None] = mapped_column(nullable=True) + + +class Subscription(Base): + __tablename__ = "subscriptions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) + plan: Mapped[str] = mapped_column(String(32), nullable=False, default="free") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + current_period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + +class Entitlement(Base): + __tablename__ = "entitlements" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + key: Mapped[str] = mapped_column(String(96), nullable=False) + value: Mapped[dict | None] = mapped_column(JSON, nullable=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False) + + +class AuthRateLimit(Base): + __tablename__ = "auth_rate_limits" + + key: Mapped[str] = mapped_column(String(255), primary_key=True) + window_started: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + attempts: Mapped[int] = mapped_column(nullable=False, default=0) diff --git a/backend/motorsignal_api/requirements.lock b/backend/motorsignal_api/requirements.lock new file mode 100644 index 0000000..6697077 --- /dev/null +++ b/backend/motorsignal_api/requirements.lock @@ -0,0 +1,38 @@ +alembic==1.19.2 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +argon2-cffi==25.1.0 +argon2-cffi-bindings==26.1.0 +cffi==2.1.1 +click==8.5.0 +cryptography==50.0.1 +dnspython==2.8.0 +email-validator==2.3.0 +exceptiongroup==1.3.1 +fastapi==0.141.1 +greenlet==3.5.5 +h11==0.16.0 +httptools==0.8.0 +idna==3.19 +Mako==1.4.1 +MarkupSafe==3.0.3 +packaging==26.3 +psycopg==3.3.5 +psycopg-binary==3.3.5 +pwdlib==0.3.1 +pycparser==3.0 +pydantic==2.13.5 +pydantic_core==2.46.5 +PyJWT==2.13.0 +python-dotenv==1.2.3 +PyYAML==6.0.3 +SQLAlchemy==2.0.52 +starlette==1.6.0 +tomli==2.4.1 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +uvicorn==0.52.4 +uvloop==0.22.1 +watchfiles==1.2.0 +websockets==16.1.1 diff --git a/backend/motorsignal_api/requirements.windows.lock b/backend/motorsignal_api/requirements.windows.lock new file mode 100644 index 0000000..0ce05b7 --- /dev/null +++ b/backend/motorsignal_api/requirements.windows.lock @@ -0,0 +1,38 @@ +# Exact production lock, excluding uvloop because uvloop does not support Windows. +alembic==1.19.2 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +argon2-cffi==25.1.0 +argon2-cffi-bindings==26.1.0 +cffi==2.1.1 +click==8.5.0 +cryptography==50.0.1 +dnspython==2.8.0 +email-validator==2.3.0 +exceptiongroup==1.3.1 +fastapi==0.141.1 +greenlet==3.5.5 +h11==0.16.0 +httptools==0.8.0 +idna==3.19 +Mako==1.4.1 +MarkupSafe==3.0.3 +packaging==26.3 +psycopg==3.3.5 +psycopg-binary==3.3.5 +pwdlib==0.3.1 +pycparser==3.0 +pydantic==2.13.5 +pydantic_core==2.46.5 +PyJWT==2.13.0 +python-dotenv==1.2.3 +PyYAML==6.0.3 +SQLAlchemy==2.0.52 +starlette==1.6.0 +tomli==2.4.1 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +uvicorn==0.52.4 +watchfiles==1.2.0 +websockets==16.1.1 diff --git a/backend/motorsignal_api/tests/test_api_smoke.py b/backend/motorsignal_api/tests/test_api_smoke.py new file mode 100644 index 0000000..8386068 --- /dev/null +++ b/backend/motorsignal_api/tests/test_api_smoke.py @@ -0,0 +1,150 @@ +import json +import os +import secrets +import urllib.error +import urllib.request +import uuid +from datetime import datetime, timedelta, timezone + +import psycopg + +import sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app")) +from auth import hash_one_time_token # noqa: E402 + + +BASE_URL = os.environ.get("TEST_BASE_URL", "http://127.0.0.1:18080") + + +def request(method: str, path: str, payload: dict | None = None, token: str | None = None): + data = json.dumps(payload).encode() if payload is not None else None + headers = {"content-type": "application/json"} + if token: + headers["authorization"] = f"Bearer {token}" + req = urllib.request.Request(f"{BASE_URL}{path}", data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as response: + body = response.read() + return response.status, json.loads(body) if body else None + except urllib.error.HTTPError as error: + body = error.read() + return error.code, json.loads(body) if body else None + + +def seed_token(table: str, user_id: str) -> str: + raw = secrets.token_urlsafe(48) + token_id = str(uuid.uuid4()) + expires = datetime.now(timezone.utc) + timedelta(hours=1) + with psycopg.connect("host=127.0.0.1 port=55432 dbname=motorsignal_dev user=motorsignal password=local-only") as connection: + with connection.cursor() as cursor: + cursor.execute( + f"INSERT INTO {table} (id, user_id, token_hash, expires_at) VALUES (%s, %s, %s, %s)", + (token_id, user_id, hash_one_time_token(raw), expires), + ) + return raw + + +def set_role(user_id: str, role: str) -> None: + with psycopg.connect("host=127.0.0.1 port=55432 dbname=motorsignal_dev user=motorsignal password=local-only") as connection: + with connection.cursor() as cursor: + cursor.execute("UPDATE users SET role = %s WHERE id = %s", (role, user_id)) + + +def reset_rate_limits() -> None: + with psycopg.connect("host=127.0.0.1 port=55432 dbname=motorsignal_dev user=motorsignal password=local-only") as connection: + with connection.cursor() as cursor: + cursor.execute("TRUNCATE auth_rate_limits") + + +def main() -> None: + reset_rate_limits() + for _ in range(10): + status, _ = request("POST", "/api/v1/auth/login", {"email": "unknown@example.com", "password": "wrong-password"}) + assert status == 401 + status, _ = request("POST", "/api/v1/auth/login", {"email": "unknown@example.com", "password": "wrong-password"}) + assert status == 429 + reset_rate_limits() + email = f"smoke-{uuid.uuid4().hex}@example.com" + status, tokens = request("POST", "/api/v1/auth/register", {"email": email, "password": "LocalTestPassword!123", "device_name": "smoke"}) + assert status == 201 and tokens["access_token"] + access = tokens["access_token"] + + status, user = request("GET", "/api/v1/me", token=access) + assert status == 200 and user["email"] == email + user_id = user["id"] + + status, sessions = request("GET", "/api/v1/auth/sessions", token=access) + assert status == 200 and len(sessions) == 1 and sessions[0]["current"] + status, _ = request("GET", "/api/v1/admin/audit-events", token=access) + assert status == 403 + + status, _ = request("POST", "/api/v1/auth/verify-email/request", {"email": email}) + assert status == 202 + verification = seed_token("email_verification_tokens", user_id) + status, _ = request("POST", "/api/v1/auth/verify-email", {"token": verification}) + assert status == 204 + + status, user = request("GET", "/api/v1/me", token=access) + assert status == 200 and user["email_verified"] is True + set_role(user_id, "admin") + + status, _ = request("POST", "/api/v1/auth/password-reset/request", {"email": email}) + assert status == 202 + reset = seed_token("password_reset_tokens", user_id) + status, _ = request("POST", "/api/v1/auth/password-reset", {"token": reset, "password": "NewLocalPassword!456"}) + assert status == 204 + status, _ = request("GET", "/api/v1/me", token=access) + assert status == 401 + + status, new_tokens = request("POST", "/api/v1/auth/login", {"email": email, "password": "NewLocalPassword!456", "device_name": "smoke-2"}) + assert status == 200 + status, sessions = request("GET", "/api/v1/auth/sessions", token=new_tokens["access_token"]) + assert status == 200 and len(sessions) == 1 + admin_access = new_tokens["access_token"] + status, created_user = request("POST", "/api/v1/admin/users", {"email": f"managed-{uuid.uuid4().hex}@example.com", "password": "ManagedPassword!123", "role": "user"}, admin_access) + assert status == 201 and created_user["role"] == "user" + status, users = request("GET", "/api/v1/admin/users?page=1&page_size=10", token=admin_access) + assert status == 200 and users["total"] >= 2 + status, created_vehicle = request("POST", "/api/v1/admin/vehicles", {"user_id": user_id, "nickname": "Test Bike", "make": "MotorSignal", "model": "Test 900", "year": 2026}, admin_access) + assert status == 201 and created_vehicle["user_id"] == user_id + status, own_vehicles = request("GET", "/api/v1/vehicles", token=admin_access) + assert status == 200 and any(item["id"] == created_vehicle["id"] for item in own_vehicles) + status, updated_vehicle = request("PATCH", f"/api/v1/admin/vehicles/{created_vehicle['id']}", {"nickname": "Updated Bike"}, admin_access) + assert status == 200 and updated_vehicle["nickname"] == "Updated Bike" + status, created_ride = request("POST", "/api/v1/admin/rides", {"user_id": user_id, "vehicle_id": created_vehicle["id"], "started_at": datetime.now(timezone.utc).isoformat(), "distance_miles": 42.5, "top_speed_mph": 88.0, "avg_speed_mph": 47.2}, admin_access) + assert status == 201 and created_ride["vehicle_id"] == created_vehicle["id"] + status, own_rides = request("GET", "/api/v1/rides", token=admin_access) + assert status == 200 and any(item["id"] == created_ride["id"] for item in own_rides["items"]) + status, telemetry = request("POST", f"/api/v1/rides/{created_ride['id']}/telemetry", {"points": [{"recorded_at": datetime.now(timezone.utc).isoformat(), "latitude": 34.5, "longitude": -83.9, "speed_mph": 42.0, "lean_angle_deg": 7.0, "g_force": 0.3, "accel_g": 0.2, "brake_g": 0.0}]}, admin_access) + assert status == 201 and telemetry["accepted"] == 1 + status, telemetry = request("GET", f"/api/v1/rides/{created_ride['id']}/telemetry", token=admin_access) + assert status == 200 and telemetry["count"] == 1 + status, telemetry = request("GET", f"/api/v1/admin/rides/{created_ride['id']}/telemetry", token=admin_access) + assert status == 200 and telemetry["count"] == 1 + status, subscription = request("GET", "/api/v1/me/subscription", token=admin_access) + assert status == 200 and subscription["plan"] == "free" + status, entitlements = request("GET", "/api/v1/me/entitlements", token=admin_access) + assert status == 200 and entitlements == [] + status, subscription = request("PATCH", f"/api/v1/admin/users/{user_id}/subscription", {"plan": "pro", "status": "active"}, admin_access) + assert status == 200 and subscription["plan"] == "pro" + status, entitlement = request("POST", f"/api/v1/admin/users/{user_id}/entitlements", {"key": "performance_launch", "value": {"enabled": True}}, admin_access) + assert status == 201 and entitlement["key"] == "performance_launch" + status, entitlements = request("GET", f"/api/v1/admin/users/{user_id}/entitlements", token=admin_access) + assert status == 200 and len(entitlements) == 1 + status, rides = request("GET", "/api/v1/admin/rides?user_id=" + user_id, token=admin_access) + assert status == 200 and rides["total"] >= 1 + status, _ = request("DELETE", f"/api/v1/admin/rides/{created_ride['id']}", token=admin_access) + assert status == 204 + status, archived_ride = request("GET", f"/api/v1/admin/rides/{created_ride['id']}", token=admin_access) + assert status == 200 and archived_ride["status"] == "archived" + status, _ = request("GET", "/api/v1/admin/audit-events", token=admin_access) + assert status == 200 + status, _ = request("POST", "/api/v1/auth/logout-all", {}, token=new_tokens["access_token"]) + assert status == 204 + status, _ = request("GET", "/api/v1/me", token=new_tokens["access_token"]) + assert status == 401 + print("API smoke passed: auth, verification, reset, sessions, RBAC, audit") + + +if __name__ == "__main__": + main() diff --git a/docs/PRODUCTION_DEPLOYMENT_CHECKLIST.md b/docs/PRODUCTION_DEPLOYMENT_CHECKLIST.md new file mode 100644 index 0000000..f7dae69 --- /dev/null +++ b/docs/PRODUCTION_DEPLOYMENT_CHECKLIST.md @@ -0,0 +1,43 @@ +# MotorSignal Production Deployment Checklist + +Prepared 2026-09-08 after read-only VPS preflight. + +## Verified + +- `motorsignal-api.service`: active +- `nginx.service`: active +- `api.motorsignal.app/api/v1/health`: healthy +- VPS Node: `20.20.2` +- VPS npm: `10.8.2` +- VPS free disk: approximately `274G` +- Current release: `/opt/motorsignal/releases/20260906-01` +- Website currently: `/var/www/motorsignal/index.html` placeholder +- API currently: Python/Uvicorn on `127.0.0.1:8080` +- Nginx currently proxies API and serves static website + +## Required operator-controlled steps + +1. Create a database backup and record its restore result. +2. Copy reviewed backend release to a new release directory. +3. Copy reviewed website release and install production Node dependencies. +4. Configure SMTP, `WEB_BASE_URL`, and a 32-byte-or-longer `JWT_SECRET` through the protected production environment file. +5. Run Alembic `upgrade head` using the production virtualenv and verify revision `d9e4f6012a35`. +6. Configure a Node systemd service for the Next website on a loopback port. +7. Update Nginx website upstream/static routing after syntax review. +8. Restart services in order, then run health, auth, account, admin, and rollback checks. +9. Keep `/opt/motorsignal/releases/20260906-01` available until acceptance completes. + +## Current blockers + +- Deployment user cannot run passwordless `sudo`; privileged Nginx/systemd/database steps need operator execution or temporary reviewed access. +- No production backup/restore proof recorded for this release. +- Admin role provisioning and SMTP values require an explicit operator decision. +- No live deployment performed. + +## Rollback + +- Restore Nginx config from its pre-release backup. +- Stop new website service if needed. +- Repoint `/opt/motorsignal/current` to prior release. +- Do not downgrade database migrations automatically; use a reviewed downgrade or forward-fix migration. +- Re-run health and auth checks. diff --git a/docs/WEBSITE_ADMIN_CURRENT_STATE.md b/docs/WEBSITE_ADMIN_CURRENT_STATE.md new file mode 100644 index 0000000..3737efa --- /dev/null +++ b/docs/WEBSITE_ADMIN_CURRENT_STATE.md @@ -0,0 +1,92 @@ +# MotorSignal Website/Admin Current State + +Audited 2026-09-08 against the implementation plan and the deployed API at +`api.motorsignal.app`. + +## Repository Boundary + +This repository is the Flutter mobile application. It contains no separate +website frontend, no FastAPI source, and no Alembic migration directory. The +production FastAPI source currently lives on the VPS under +`/opt/motorsignal/current/app`; it is not available as local versioned source. + +The production website at `motorsignal.app` is currently a placeholder static +page. It is not an admin panel. + +## Verified API Surface + +The deployed FastAPI application currently exposes: + +- `GET /api/v1/health` +- `GET /api/v1/ready` +- `POST /api/v1/auth/register` +- `POST /api/v1/auth/login` +- `POST /api/v1/auth/refresh` +- `POST /api/v1/auth/logout` +- `GET /api/v1/me` + +Authentication already has password hashing, access tokens, refresh-token +hashing/rotation, per-device sessions, and session validation for the current +user. + +## Implemented Locally In This Checkpoint + +The recovered backend now includes a reversible Alembic revision for: + +- User roles with a default `user` role +- Email-verification and password-reset token persistence +- Audit-event persistence with actor/target, request metadata, and timestamps + +The API now includes email verification request/consume flows, password reset +request/consume flows, session listing/revocation/logout-all, role dependencies, +and a support/admin audit-event read endpoint. It also includes role-protected +paginated admin CRUD for users and vehicles, plus ride CRUD with archive-on-delete +semantics. SMTP delivery is optional and configured only through environment +variables; tokens are never returned by the request endpoints. + +User-scoped vehicle and ride list/read/create/update/delete-or-archive access is +also available under `/api/v1/vehicles` and `/api/v1/rides`, scoped from the +authenticated session rather than caller-supplied ownership. + +Ride-linked telemetry persistence is available through bounded batch ingestion +at `/api/v1/rides/{ride_id}/telemetry`, owner-scoped reads, and support/admin +reads. Points are indexed by ride and timestamp. + +Subscription and entitlement baselines are available through authenticated +account reads and admin-controlled updates. Default subscription is `free`; no +payment provider or billing state is fabricated. + +Database-backed authentication rate limits now protect login, registration, +verification, and password-reset paths. Limit is 10 attempts per client IP per +15-minute window, with `Retry-After` responses. + +## Still Missing Before Admin UI + +These plan requirements are not present in the deployed API: + +- Telemetry, subscriptions, and entitlement models +- Authentication rate limiting beyond the existing Nginx request limit + +Production deployment remains pending review. Local runtime verification uses +the recovered lock and a Windows-compatible test lock. + +## Local Verification Checkpoint + +The exact production dependency set was recovered from the running production +virtualenv with `pip freeze` and saved as +`backend/motorsignal_api/requirements.lock`. Parity against a fresh production +freeze passed. Windows uses a separate test lock excluding only `uvloop`, which +does not support Windows. + +Local PostgreSQL 16 runs in Docker on `127.0.0.1:55432`. Alembic upgrade from an +empty database passed at revision `d9e4f6012a35`. Repeatable API smoke tests pass +for registration, email verification, password reset, sessions, RBAC denial, +audit writes, and logout-all. No production migration or API deployment was +performed. + +## Required Next Checkpoint + +Obtain the FastAPI/Alembic source into a versioned local backend project before +editing authentication, authorization, schema, or production deployment. Do not +implement admin CRUD against the current API: it has no server-side admin +authorization or resource endpoints to secure the panel. diff --git a/lib/features/dashboard/widgets/g_force_indicator.dart b/lib/features/dashboard/widgets/g_force_indicator.dart index fffbef6..905b9e4 100644 --- a/lib/features/dashboard/widgets/g_force_indicator.dart +++ b/lib/features/dashboard/widgets/g_force_indicator.dart @@ -29,7 +29,7 @@ class GForceIndicator extends ConsumerWidget { final currentBraking = gForce.accel < 0 ? gForce.accel.abs() : 0.0; final isDark = Theme.of(context).brightness == Brightness.dark; - final dialRadius = compact ? 34.0 : 44.0; + final dialRadius = compact ? 34.0 : 52.0; final accelTextColor = gForce.speed < 3.0 ? (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant) @@ -134,8 +134,8 @@ class GForceIndicator extends ConsumerWidget { Transform.translate( offset: Offset(targetX, targetY), child: Container( - width: compact ? 8 : 10, - height: compact ? 8 : 10, + width: compact ? 8 : 11, + height: compact ? 8 : 11, decoration: BoxDecoration( color: dotColor, shape: BoxShape.circle, diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart index dec27e9..197c08b 100644 --- a/lib/features/dashboard/widgets/lean_angle_indicator.dart +++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart @@ -43,7 +43,7 @@ class LeanAngleIndicator extends ConsumerWidget { direction = 'UPRIGHT'; } - final dialSize = compact ? 80.0 : 104.0; + final dialSize = compact ? 80.0 : 124.0; return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart index efbfd19..16e9e29 100644 --- a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart +++ b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/responsive/viewport_size.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/theme_extensions.dart'; import '../../rides/ride_state_machine.dart'; import '../dashboard_config.dart'; import 'dashboard_layout_switcher.dart'; @@ -39,8 +41,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ), child: snapshot.isLandscape ? (snapshot.isShortViewport - ? _buildShortLandscapeLayout(context, layoutMode, session) - : _buildExpandedLayout(context, layoutMode, session, snapshot)) + ? _buildShortLandscapeLayout(context, layoutMode, session) + : _buildExpandedLayout(context, layoutMode, session, snapshot)) : _buildCompactPortraitLayout(context, layoutMode, session), ); } @@ -55,7 +57,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ) { return LayoutBuilder( builder: (context, constraints) { - final isShortPortrait = constraints.maxHeight < 620; + // Group-ride alerts reduce the usable height above this layout. + final isShortPortrait = constraints.maxHeight < 760; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -98,14 +101,18 @@ class ResponsiveDashboardLayout extends ConsumerWidget { // Body based on mode if (mode == DashboardLayout.minimal) ...[ - if (!isShortPortrait) const Spacer(flex: 2) else const Spacer(flex: 1), + if (!isShortPortrait) + const Spacer(flex: 2) + else + const Spacer(flex: 1), const Flexible( flex: 6, - child: Center( - child: SpeedReadout(size: SpeedReadoutSize.hero), - ), + child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)), ), - if (!isShortPortrait) const Spacer(flex: 2) else const Spacer(flex: 1), + if (!isShortPortrait) + const Spacer(flex: 2) + else + const Spacer(flex: 1), Container( padding: EdgeInsets.symmetric( horizontal: 16, @@ -141,12 +148,13 @@ class ResponsiveDashboardLayout extends ConsumerWidget { RideActionButton(compact: isShortPortrait), ] else if (mode == DashboardLayout.touring) ...[ SizedBox(height: isShortPortrait ? 2 : 6), - SpeedReadout(size: isShortPortrait ? SpeedReadoutSize.compact : SpeedReadoutSize.compact), - SizedBox(height: isShortPortrait ? 4 : 8), - const Expanded( - flex: 5, - child: DashboardMapPanel(), + SpeedReadout( + size: isShortPortrait + ? SpeedReadoutSize.compact + : SpeedReadoutSize.compact, ), + SizedBox(height: isShortPortrait ? 4 : 8), + const Expanded(flex: 5, child: DashboardMapPanel()), SizedBox(height: isShortPortrait ? 4 : 10), Container( padding: EdgeInsets.symmetric( @@ -206,8 +214,11 @@ class ResponsiveDashboardLayout extends ConsumerWidget { if (!isShortPortrait) const Spacer(flex: 1), Flexible( flex: isShortPortrait ? 3 : 4, - child: const Center( - child: SpeedReadout(size: SpeedReadoutSize.hero), + child: Center( + child: Transform.translate( + offset: Offset(0, -12), + child: SpeedReadout(size: SpeedReadoutSize.hero), + ), ), ), if (!isShortPortrait) const Spacer(flex: 1), @@ -266,6 +277,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ), SizedBox(height: isShortPortrait ? 4 : 14), RideActionButton(compact: isShortPortrait), + if (!isShortPortrait) ...[ + const SizedBox(height: 6), + const PerformanceLaunchShortcut(), + ], ], ], ); @@ -285,10 +300,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget { return Row( children: [ // Left side: Mode-driven embedded map - const Expanded( - flex: 55, - child: DashboardMapPanel(), - ), + const Expanded(flex: 55, child: DashboardMapPanel()), const SizedBox(width: 8), // Right side: Cockpit telemetry Expanded( @@ -353,9 +365,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ], ), const Expanded( - child: Center( - child: SpeedReadout(size: SpeedReadoutSize.hero), - ), + child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)), ), SizedBox( width: 140, @@ -423,10 +433,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget { RideStatusChip(), ], ), - const Center( - child: SpeedReadout(size: SpeedReadoutSize.compact), - ), + const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)), const RideActionButton(compact: true), + const SizedBox(height: 6), + const PerformanceLaunchShortcut(compact: true), ], ), ), @@ -470,10 +480,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget { if (mode == DashboardLayout.touring) { return Row( children: [ - const Expanded( - flex: 60, - child: DashboardMapPanel(), - ), + const Expanded(flex: 60, child: DashboardMapPanel()), const SizedBox(width: 16), Expanded( flex: 40, @@ -490,7 +497,9 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ], ), const Spacer(), - const Center(child: SpeedReadout(size: SpeedReadoutSize.regular)), + const Center( + child: SpeedReadout(size: SpeedReadoutSize.regular), + ), const Spacer(), Container( padding: const EdgeInsets.all(14), @@ -616,10 +625,15 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ), ], ), - const Center( - child: SpeedReadout(size: SpeedReadoutSize.hero), + Center( + child: Transform.translate( + offset: Offset(0, -20), + child: SpeedReadout(size: SpeedReadoutSize.hero), + ), ), const RideActionButton(), + const SizedBox(height: 6), + const PerformanceLaunchShortcut(), ], ), ), @@ -669,12 +683,48 @@ class ResponsiveDashboardLayout extends ConsumerWidget { BoxDecoration _boxDecoration(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return BoxDecoration( - color: isDark ? AppColors.darkSurfaceContainer : AppColors.lightSurfaceContainer, + color: isDark + ? AppColors.darkSurfaceContainer + : AppColors.lightSurfaceContainer, borderRadius: BorderRadius.circular(12), border: Border.all( - color: isDark ? AppColors.darkOutlineVariant : AppColors.lightOutlineVariant, + color: isDark + ? AppColors.darkOutlineVariant + : AppColors.lightOutlineVariant, width: 1.0, ), ); } } + +/// Fast dashboard entry point for the launch timer used before a run. +class PerformanceLaunchShortcut extends StatelessWidget { + final bool compact; + + const PerformanceLaunchShortcut({super.key, this.compact = false}); + + @override + Widget build(BuildContext context) { + final colors = context.motorColors; + return SizedBox( + width: double.infinity, + height: compact ? 38 : 42, + child: OutlinedButton.icon( + onPressed: () => context.push('/launch'), + icon: Icon(Icons.rocket_launch, size: compact ? 16 : 18), + label: const Text('PERFORMANCE LAUNCH'), + style: OutlinedButton.styleFrom( + foregroundColor: colors.launchGreen, + side: BorderSide(color: colors.launchGreen.withValues(alpha: 0.75)), + padding: const EdgeInsets.symmetric(horizontal: 10), + textStyle: TextStyle( + fontSize: compact ? 10 : 11, + fontWeight: FontWeight.w800, + letterSpacing: 0.35, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ); + } +} diff --git a/lib/features/routes/widgets/address_autocomplete_field.dart b/lib/features/routes/widgets/address_autocomplete_field.dart index c88f0f1..45d40e6 100644 --- a/lib/features/routes/widgets/address_autocomplete_field.dart +++ b/lib/features/routes/widgets/address_autocomplete_field.dart @@ -81,7 +81,7 @@ class _AddressAutocompleteFieldState _controller = widget.controller!; } else { _controller = TextEditingController( - text: widget.initialPlace?.displayName ?? widget.initialQuery ?? '', + text: _displayText(widget.initialPlace) ?? widget.initialQuery ?? '', ); _internalController = true; } @@ -112,7 +112,7 @@ class _AddressAutocompleteFieldState super.didUpdateWidget(oldWidget); if (widget.initialPlace != oldWidget.initialPlace) { _resolvedPlace = widget.initialPlace; - final newText = widget.initialPlace?.displayName ?? ''; + final newText = _displayText(widget.initialPlace) ?? ''; if (_controller.text != newText) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _controller.text != newText) { @@ -124,6 +124,13 @@ class _AddressAutocompleteFieldState } } + String? _displayText(ResolvedPlace? place) { + final formatted = place?.formattedAddress?.trim(); + return formatted == null || formatted.isEmpty + ? place?.displayName + : formatted; + } + @override void dispose() { _debounceTimer?.cancel(); @@ -204,13 +211,20 @@ class _AddressAutocompleteFieldState final msg = e is RouteProviderException ? e.message - : e.toString() - .replaceAll('RouteProviderException: ', '') - .replaceAll('Exception: ', ''); + : e + .toString() + .replaceAll('RouteProviderException: ', '') + .replaceAll('Exception: ', ''); + + final userMessage = msg.contains('STADIA_API_KEY') + ? 'Address search is unavailable. The routing server needs a Stadia Maps API key.' + : msg; setState(() { _isLoading = false; - _errorMessage = msg.isNotEmpty ? msg : 'Unable to search addresses.'; + _errorMessage = userMessage.isNotEmpty + ? userMessage + : 'Unable to search addresses.'; }); } } @@ -245,8 +259,9 @@ class _AddressAutocompleteFieldState void _applyResolvedPlace(ResolvedPlace place) { _resolvedPlace = place; - _lastText = place.displayName; - _controller.text = place.displayName; + final displayText = _displayText(place) ?? place.displayName; + _lastText = displayText; + _controller.text = displayText; setState(() { _isResolving = false; _suggestions = const []; @@ -308,10 +323,14 @@ class _AddressAutocompleteFieldState hintStyle: const TextStyle(color: Colors.white24, fontSize: 13), filled: true, fillColor: colors.surfaceContainerHigh, - prefixIcon: widget.prefixIcon ?? + prefixIcon: + widget.prefixIcon ?? (isResolved - ? Icon(Icons.check_circle, - color: colors.launchGreen, size: 20) + ? Icon( + Icons.check_circle, + color: colors.launchGreen, + size: 20, + ) : Icon(Icons.search, color: colors.cyberCyan, size: 20)), suffixIcon: _isResolving ? Padding( @@ -326,13 +345,16 @@ class _AddressAutocompleteFieldState ), ) : _controller.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, - size: 18, color: Colors.white54), - onPressed: _clear, - tooltip: 'Clear location', - ) - : null, + ? IconButton( + icon: const Icon( + Icons.clear, + size: 18, + color: Colors.white54, + ), + onPressed: _clear, + tooltip: 'Clear location', + ) + : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide( @@ -383,128 +405,144 @@ class _AddressAutocompleteFieldState borderRadius: BorderRadius.circular(8), child: SingleChildScrollView( child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_isLoading) - Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colors.cyberCyan, - ), - ), - const SizedBox(width: 12), - const Text( - 'Searching addresses...', - style: TextStyle(color: Colors.white70, fontSize: 12), - ), - ], - ), - ) - else if (_errorMessage != null) - Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon(Icons.warning_amber_rounded, - color: colors.warningAmber, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - _errorMessage!, - style: const TextStyle( - color: Colors.white70, fontSize: 12), - ), - ), - TextButton( - onPressed: () => _performSearch(_controller.text.trim()), - child: Text( - 'RETRY', - style: - TextStyle(color: colors.cyberCyan, fontSize: 11), - ), - ), - ], - ), - ) - else if (_hasSearched && _suggestions.isEmpty) - const Padding( - padding: EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.location_off, color: Colors.white38, size: 16), - SizedBox(width: 8), - Text( - 'No matching addresses found', - style: TextStyle(color: Colors.white54, fontSize: 12), - ), - ], - ), - ) - else - for (int i = 0; i < _suggestions.length; i++) ...[ - if (i > 0) const Divider(height: 1, color: Colors.white10), - ListTile( - dense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 2), - leading: Icon( - Icons.place_outlined, - color: colors.cyberCyan, - size: 20, - ), - title: Text( - _suggestions[i].primaryText, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: _suggestions[i].secondaryText.isNotEmpty - ? Text( - _suggestions[i].secondaryText, - style: const TextStyle( - color: Colors.white54, - fontSize: 11, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isLoading) + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.cyberCyan, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, - onTap: () => _selectSuggestion(_suggestions[i]), - ), - ], - if (widget.showAttribution) - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - color: colors.surfaceContainer, - child: const Text( - 'Search by Stadia Maps / Pelias • © OpenStreetMap contributors', - style: TextStyle( - color: Colors.white38, - fontSize: 9, - letterSpacing: 0.2, + ), + const SizedBox(width: 12), + const Text( + 'Searching addresses...', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ) + else if (_errorMessage != null) + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: colors.warningAmber, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + ), + TextButton( + onPressed: () => + _performSearch(_controller.text.trim()), + child: Text( + 'RETRY', + style: TextStyle( + color: colors.cyberCyan, + fontSize: 11, + ), + ), + ), + ], + ), + ) + else if (_hasSearched && _suggestions.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.location_off, + color: Colors.white38, + size: 16, + ), + SizedBox(width: 8), + Text( + 'No matching addresses found', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ) + else + for (int i = 0; i < _suggestions.length; i++) ...[ + if (i > 0) const Divider(height: 1, color: Colors.white10), + ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 2, + ), + leading: Icon( + Icons.place_outlined, + color: colors.cyberCyan, + size: 20, + ), + title: Text( + _suggestions[i].primaryText, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: _suggestions[i].secondaryText.isNotEmpty + ? Text( + _suggestions[i].secondaryText, + style: const TextStyle( + color: Colors.white54, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + onTap: () => _selectSuggestion(_suggestions[i]), + ), + ], + if (widget.showAttribution) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + color: colors.surfaceContainer, + child: const Text( + 'Search by Stadia Maps / Pelias • © OpenStreetMap contributors', + style: TextStyle( + color: Colors.white38, + fontSize: 9, + letterSpacing: 0.2, + ), + textAlign: TextAlign.center, + ), ), - textAlign: TextAlign.center, - ), - ), - ], + ], + ), ), ), ), - ), - ); + ); + } } -} - diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 1f123bb..6e9295e 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -155,45 +155,53 @@ class _SettingsScreenState extends ConsumerState { return ListView( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), children: [ - _buildSectionHeader('Membership & Entitlement'), - const SizedBox(height: 10), - const CurrentPlanCard(), - const SizedBox(height: 24), - _buildSectionHeader('Account & Cloud Identity'), - const SizedBox(height: 10), - _buildAccountCard(context), - const SizedBox(height: 24), - _buildSectionHeader('Appearance & Display Theme'), - const SizedBox(height: 10), - _buildAppearanceCard(context), - const SizedBox(height: 24), - _buildSectionHeader('Sensors & Calibration'), - const SizedBox(height: 10), - _buildSensorsCard(context), - const SizedBox(height: 24), - _buildSectionHeader('Maps & Navigation Engine'), - const SizedBox(height: 10), - _buildMapsCard(context), - const SizedBox(height: 24), - _buildSectionHeader('Road Alerts & Incidents'), - const SizedBox(height: 10), - _buildRoadAlertsCard(context), - const SizedBox(height: 16), - _buildCommunityProviderCard(context), - const SizedBox(height: 16), - _buildTomTomProviderCard(context), - if (kDebugMode) ...[ - const SizedBox(height: 16), - _buildDeveloperAlertsCard(context), - ], - const SizedBox(height: 24), - _buildSectionHeader('Data & Diagnostics'), - const SizedBox(height: 10), - _buildDiagnosticsLinkCard(context), - const SizedBox(height: 12), - _buildClearHistoryCard(context), - const SizedBox(height: 16), - _buildFieldTestSection(context), + _buildSettingsSection( + context, + title: 'Membership & Entitlement', + icon: Icons.workspace_premium_outlined, + initiallyExpanded: true, + children: const [CurrentPlanCard()], + ), + _buildSettingsSection( + context, + title: 'Account & Cloud Identity', + icon: Icons.account_circle_outlined, + initiallyExpanded: true, + children: [_buildAccountCard(context)], + ), + _buildSettingsSection( + context, + title: 'Appearance & Sensors', + icon: Icons.tune, + children: [_buildAppearanceCard(context), _buildSensorsCard(context)], + ), + _buildSettingsSection( + context, + title: 'Maps & Navigation', + icon: Icons.map_outlined, + children: [_buildMapsCard(context)], + ), + _buildSettingsSection( + context, + title: 'Road Alerts & Providers', + icon: Icons.warning_amber_outlined, + children: [ + _buildRoadAlertsCard(context), + _buildCommunityProviderCard(context), + _buildTomTomProviderCard(context), + if (kDebugMode) _buildDeveloperAlertsCard(context), + ], + ), + _buildSettingsSection( + context, + title: 'Data & Diagnostics', + icon: Icons.fact_check_outlined, + children: [ + _buildDiagnosticsLinkCard(context), + _buildClearHistoryCard(context), + _buildFieldTestSection(context), + ], + ), const SizedBox(height: 28), const _AppVersionFooter(), const SizedBox(height: 16), @@ -201,6 +209,43 @@ class _SettingsScreenState extends ConsumerState { ); } + Widget _buildSettingsSection( + BuildContext context, { + required String title, + required IconData icon, + required List children, + bool initiallyExpanded = false, + }) { + final colors = context.motorColors; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ExpansionTile( + initiallyExpanded: initiallyExpanded, + tilePadding: const EdgeInsets.symmetric(horizontal: 12), + childrenPadding: const EdgeInsets.fromLTRB(0, 0, 0, 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: colors.outlineVariant), + ), + collapsedShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: colors.outlineVariant), + ), + leading: Icon(icon, color: colors.cyberCyan), + title: Text( + title, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w800), + ), + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + children[i], + ], + ], + ), + ); + } + Widget _buildSectionHeader(String title) { return Text( title, diff --git a/lib/features/social/clubs/create_club_screen.dart b/lib/features/social/clubs/create_club_screen.dart index 7dd9fc7..c782ea6 100644 --- a/lib/features/social/clubs/create_club_screen.dart +++ b/lib/features/social/clubs/create_club_screen.dart @@ -81,8 +81,12 @@ class _CreateClubScreenState extends ConsumerState { description: _descController.text.trim(), visibility: _visibility, admissionPolicy: _admissionPolicy, - homeRegion: _regionController.text.trim().isEmpty ? null : _regionController.text.trim(), - motorcycleFocus: _focusController.text.trim().isEmpty ? null : _focusController.text.trim(), + homeRegion: _regionController.text.trim().isEmpty + ? null + : _regionController.text.trim(), + motorcycleFocus: _focusController.text.trim().isEmpty + ? null + : _focusController.text.trim(), callerUid: user.uid, ); @@ -105,7 +109,8 @@ class _CreateClubScreenState extends ConsumerState { setState(() { _isSubmitting = false; if (e.code == 'slug-taken') { - _submissionError = 'This handle was just taken by another club. Please pick a different handle.'; + _submissionError = + 'This handle was just taken by another club. Please pick a different handle.'; } else { _submissionError = e.message; } @@ -114,7 +119,8 @@ class _CreateClubScreenState extends ConsumerState { if (!mounted) return; setState(() { _isSubmitting = false; - _submissionError = 'An unexpected error occurred while creating the club: ${ErrorMapper.map(e)}'; + _submissionError = + 'An unexpected error occurred while creating the club: ${ErrorMapper.map(e)}'; }); } } @@ -199,12 +205,19 @@ class _CreateClubScreenState extends ConsumerState { decoration: BoxDecoration( color: colors.surfaceContainer, borderRadius: BorderRadius.circular(12), - border: Border.all(color: colors.outlineVariant, width: 0.8), + border: Border.all( + color: colors.outlineVariant, + width: 0.8, + ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.shield_outlined, color: colors.cyberCyan, size: 24), + Icon( + Icons.shield_outlined, + color: colors.cyberCyan, + size: 24, + ), const SizedBox(width: 12), Expanded( child: Column( @@ -243,11 +256,17 @@ class _CreateClubScreenState extends ConsumerState { decoration: BoxDecoration( color: colors.raceRed.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), - border: Border.all(color: colors.raceRed.withValues(alpha: 0.4)), + border: Border.all( + color: colors.raceRed.withValues(alpha: 0.4), + ), ), child: Row( children: [ - Icon(Icons.error_outline, color: colors.raceRed, size: 20), + Icon( + Icons.error_outline, + color: colors.raceRed, + size: 20, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -277,21 +296,34 @@ class _CreateClubScreenState extends ConsumerState { fillColor: colors.surfaceContainer, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.outlineVariant, width: 0.8), + borderSide: BorderSide( + color: colors.outlineVariant, + width: 0.8, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.outlineVariant, width: 0.8), + borderSide: BorderSide( + color: colors.outlineVariant, + width: 0.8, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.cyberCyan, width: 1.5), + borderSide: BorderSide( + color: colors.cyberCyan, + width: 1.5, + ), ), ), validator: (val) { final clean = val?.trim() ?? ''; - if (clean.length < 3) return 'Name must be at least 3 characters'; - if (clean.length > 50) return 'Name cannot exceed 50 characters'; + if (clean.length < 3) { + return 'Name must be at least 3 characters'; + } + if (clean.length > 50) { + return 'Name cannot exceed 50 characters'; + } return null; }, ), @@ -318,74 +350,102 @@ class _CreateClubScreenState extends ConsumerState { textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( labelText: 'Description (Optional)', - hintText: 'Share your club mission, riding style, or club guidelines...', + hintText: + 'Share your club mission, riding style, or club guidelines...', filled: true, fillColor: colors.surfaceContainer, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.outlineVariant, width: 0.8), + borderSide: BorderSide( + color: colors.outlineVariant, + width: 0.8, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.outlineVariant, width: 0.8), + borderSide: BorderSide( + color: colors.outlineVariant, + width: 0.8, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.cyberCyan, width: 1.5), + borderSide: BorderSide( + color: colors.cyberCyan, + width: 1.5, + ), ), ), ), const SizedBox(height: 14), - // Home Region & Focus Row - Row( - children: [ - Expanded( - child: TextFormField( - controller: _regionController, - enabled: !_isSubmitting, - textCapitalization: TextCapitalization.words, - decoration: InputDecoration( - labelText: 'Home Region', - hintText: 'e.g. NorCal', - prefixIcon: const Icon(Icons.location_on_outlined, size: 18), - filled: true, - fillColor: colors.surfaceContainer, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.outlineVariant, width: 0.8), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.outlineVariant, width: 0.8), - ), - ), + // Give optional discovery fields enough room for a clear + // label and example on narrow phone layouts. + _buildFieldLabel( + 'Home base', + 'Where this club primarily rides or organizes from.', + colors, + ), + const SizedBox(height: 8), + TextFormField( + controller: _regionController, + enabled: !_isSubmitting, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: 'Home region (optional)', + hintText: 'e.g. North Georgia, NorCal, Phoenix Metro', + prefixIcon: const Icon(Icons.location_on_outlined), + filled: true, + fillColor: colors.surfaceContainer, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colors.outlineVariant, + width: 0.8, ), ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - controller: _focusController, - enabled: !_isSubmitting, - textCapitalization: TextCapitalization.words, - decoration: InputDecoration( - labelText: 'Motorcycle Focus', - hintText: 'e.g. Touring, Adventure, Commuting', - prefixIcon: const Icon(Icons.two_wheeler_outlined, size: 18), - filled: true, - fillColor: colors.surfaceContainer, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.outlineVariant, width: 0.8), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colors.outlineVariant, width: 0.8), - ), - ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colors.outlineVariant, + width: 0.8, ), ), - ], + ), + ), + const SizedBox(height: 18), + _buildFieldLabel( + 'Rider community', + 'Name the motorcycle types or riding style this club is for.', + colors, + ), + const SizedBox(height: 8), + TextFormField( + controller: _focusController, + enabled: !_isSubmitting, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: 'Motorcycle focus (optional)', + hintText: + 'e.g. Sport bikes, ADV, Cruisers, Mixed rides', + prefixIcon: const Icon(Icons.two_wheeler_outlined), + filled: true, + fillColor: colors.surfaceContainer, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colors.outlineVariant, + width: 0.8, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colors.outlineVariant, + width: 0.8, + ), + ), + ), ), const SizedBox(height: 24), @@ -422,13 +482,16 @@ class _CreateClubScreenState extends ConsumerState { height: 50, child: FilledButton( key: const ValueKey('create_club_submit_button'), - onPressed: (_isSubmitting || !_isSlugValidAndAvailable) + onPressed: + (_isSubmitting || !_isSlugValidAndAvailable) ? null : _submitForm, style: FilledButton.styleFrom( backgroundColor: colors.cyberCyan, foregroundColor: Colors.black, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), child: _isSubmitting ? const SizedBox( @@ -436,7 +499,9 @@ class _CreateClubScreenState extends ConsumerState { height: 24, child: CircularProgressIndicator( strokeWidth: 2.5, - valueColor: AlwaysStoppedAnimation(Colors.black), + valueColor: AlwaysStoppedAnimation( + Colors.black, + ), ), ) : const Text( @@ -462,137 +527,94 @@ class _CreateClubScreenState extends ConsumerState { ); } - Widget _buildVisibilitySelector(MotorSignalThemeColors colors, ThemeData theme) { - return Column( - children: [ - _buildChoiceTile( - title: 'Public', - subtitle: 'Discoverable in search and public directory. Any rider can find the club.', - selected: _visibility == ClubVisibility.public, - colors: colors, - theme: theme, - onTap: () => setState(() => _visibility = ClubVisibility.public), - ), - const SizedBox(height: 8), - _buildChoiceTile( - title: 'Unlisted', - subtitle: 'Hidden from public discovery. Accessible via direct handle or invitation.', - selected: _visibility == ClubVisibility.unlisted, - colors: colors, - theme: theme, - onTap: () => setState(() => _visibility = ClubVisibility.unlisted), - ), - const SizedBox(height: 8), - _buildChoiceTile( - title: 'Private', - subtitle: 'Completely hidden. Visible only to active members and leadership.', - selected: _visibility == ClubVisibility.private, - colors: colors, - theme: theme, - onTap: () => setState(() => _visibility = ClubVisibility.private), - ), - ], + Widget _buildVisibilitySelector( + MotorSignalThemeColors colors, + ThemeData theme, + ) { + return DropdownButtonFormField( + initialValue: _visibility, + decoration: _choiceDecoration('Who can find this club', colors), + dropdownColor: colors.surfaceContainerHigh, + style: const TextStyle(color: Colors.white), + items: ClubVisibility.values + .map( + (value) => DropdownMenuItem(value: value, child: Text(value.label)), + ) + .toList(), + onChanged: _isSubmitting + ? null + : (value) { + if (value != null) setState(() => _visibility = value); + }, ); } - Widget _buildAdmissionSelector(MotorSignalThemeColors colors, ThemeData theme) { - return Column( - children: [ - _buildChoiceTile( - title: 'Request to Join (Recommended)', - subtitle: 'Riders submit a join request for leadership review before becoming active members.', - selected: _admissionPolicy == ClubAdmissionPolicy.request, - colors: colors, - theme: theme, - onTap: () => setState(() => _admissionPolicy = ClubAdmissionPolicy.request), - ), - const SizedBox(height: 8), - _buildChoiceTile( - title: 'Open to Join', - subtitle: 'Any rider can instantly join and become an active member without prior approval.', - selected: _admissionPolicy == ClubAdmissionPolicy.open, - colors: colors, - theme: theme, - onTap: () => setState(() => _admissionPolicy = ClubAdmissionPolicy.open), - ), - const SizedBox(height: 8), - _buildChoiceTile( - title: 'Invite Only', - subtitle: 'Riders cannot submit join requests. Only club leadership can invite members.', - selected: _admissionPolicy == ClubAdmissionPolicy.inviteOnly, - colors: colors, - theme: theme, - onTap: () => setState(() => _admissionPolicy = ClubAdmissionPolicy.inviteOnly), - ), - ], + Widget _buildAdmissionSelector( + MotorSignalThemeColors colors, + ThemeData theme, + ) { + return DropdownButtonFormField( + initialValue: _admissionPolicy, + decoration: _choiceDecoration('How riders join', colors), + dropdownColor: colors.surfaceContainerHigh, + style: const TextStyle(color: Colors.white), + items: ClubAdmissionPolicy.values + .map( + (value) => DropdownMenuItem(value: value, child: Text(value.label)), + ) + .toList(), + onChanged: _isSubmitting + ? null + : (value) { + if (value != null) setState(() => _admissionPolicy = value); + }, ); } - Widget _buildChoiceTile({ - required String title, - required String subtitle, - required bool selected, - required MotorSignalThemeColors colors, - required ThemeData theme, - required VoidCallback onTap, - }) { - return Container( - decoration: BoxDecoration( - color: selected - ? colors.cyberCyan.withValues(alpha: 0.08) - : colors.surfaceContainer, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: selected ? colors.cyberCyan : colors.outlineVariant, - width: selected ? 1.5 : 0.8, - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: _isSubmitting ? null : onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - children: [ - Icon( - selected ? Icons.radio_button_checked : Icons.radio_button_off, - color: selected ? colors.cyberCyan : colors.outline, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 13.5, - fontWeight: FontWeight.w600, - color: selected ? colors.cyberCyan : null, - ), - ), - const SizedBox(height: 2), - Text( - subtitle, - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 11.5, - color: theme.colorScheme.onSurfaceVariant, - height: 1.25, - ), - ), - ], - ), - ), - ], - ), + Widget _buildFieldLabel( + String title, + String description, + MotorSignalThemeColors colors, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: colors.cyberCyan, + fontWeight: FontWeight.w700, + fontSize: 13, ), ), + const SizedBox(height: 3), + Text( + description, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + height: 1.25, + ), + ), + ], + ); + } + + InputDecoration _choiceDecoration( + String label, + MotorSignalThemeColors colors, + ) { + return InputDecoration( + labelText: label, + prefixIcon: const Icon(Icons.tune), + filled: true, + fillColor: colors.surfaceContainer, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colors.outlineVariant), ), ); } + } diff --git a/lib/main.dart b/lib/main.dart index d9829a9..2e49012 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:app_links/app_links.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:mcp_toolkit/mcp_toolkit.dart'; import 'firebase_options.dart'; import 'core/theme/theme.dart'; import 'core/theme/theme_mode_provider.dart'; @@ -23,6 +24,10 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); } + MCPToolkitBinding.instance + ..initialize() + ..initializeFlutterToolkit(); + try { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, diff --git a/lib/services/routes/valhalla_route_provider.dart b/lib/services/routes/valhalla_route_provider.dart index 244a2ba..b0f1b8b 100644 --- a/lib/services/routes/valhalla_route_provider.dart +++ b/lib/services/routes/valhalla_route_provider.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:flutter/foundation.dart'; import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; @@ -18,8 +18,8 @@ class ValhallaRouteProvider implements RouteProvider { String? backendUrl, http.Client? client, this.tokenProvider, - }) : _client = client ?? http.Client(), - backendUrl = backendUrl ?? _getEffectiveBackendUrl(); + }) : _client = client ?? http.Client(), + backendUrl = backendUrl ?? _getEffectiveBackendUrl(); static String _getEffectiveBackendUrl() { const definedUrl = String.fromEnvironment('MOTORSIGNAL_API_BASE_URL'); @@ -27,7 +27,9 @@ class ValhallaRouteProvider implements RouteProvider { return definedUrl; } if (kReleaseMode) { - throw StateError('MOTORSIGNAL_API_BASE_URL is not configured for production build.'); + throw StateError( + 'MOTORSIGNAL_API_BASE_URL is not configured for production build.', + ); } return 'http://10.0.2.2:3000'; } @@ -85,6 +87,11 @@ class ValhallaRouteProvider implements RouteProvider { } } catch (_) {} + if (code == 'providerUnavailable') { + msg = + 'Places service is not configured. Set STADIA_API_KEY on the routing server.'; + } + throw RouteProviderException(code, msg); } @@ -99,16 +106,15 @@ class ValhallaRouteProvider implements RouteProvider { final headers = await _getAuthHeaders(); try { - final queryParams = { - 'query': clean, - }; + final queryParams = {'query': clean}; if (nearLocation != null) { queryParams['lat'] = nearLocation.latitude.toString(); queryParams['lng'] = nearLocation.longitude.toString(); } - final uri = Uri.parse('$backendUrl/api/places/search') - .replace(queryParameters: queryParams); + final uri = Uri.parse( + '$backendUrl/api/places/search', + ).replace(queryParameters: queryParams); final response = await _client .get(uri, headers: headers) @@ -162,8 +168,9 @@ class ValhallaRouteProvider implements RouteProvider { final headers = await _getAuthHeaders(); try { - final uri = Uri.parse('$backendUrl/api/places/resolve') - .replace(queryParameters: {'placeId': cleanId}); + final uri = Uri.parse( + '$backendUrl/api/places/resolve', + ).replace(queryParameters: {'placeId': cleanId}); final response = await _client .get(uri, headers: headers) @@ -227,11 +234,7 @@ class ValhallaRouteProvider implements RouteProvider { final headers = await _getAuthHeaders(); final locations = ordered - .map((s) => { - 'lat': s.latitude, - 'lon': s.longitude, - 'type': 'break', - }) + .map((s) => {'lat': s.latitude, 'lon': s.longitude, 'type': 'break'}) .toList(); try { @@ -246,7 +249,7 @@ class ValhallaRouteProvider implements RouteProvider { 'avoid_highways': avoidHighways, 'avoid_tolls': avoidTolls, 'avoid_ferries': avoidFerries, - } + }, }), ) .timeout(const Duration(seconds: 15)); @@ -262,15 +265,23 @@ class ValhallaRouteProvider implements RouteProvider { } final encodedPolyline = data['encodedPolyline'] as String? ?? ''; + final encodedPolylines = (data['encodedPolylines'] as List?) + ?.whereType() + .where((part) => part.isNotEmpty) + .toList(growable: false); final distanceMeters = (data['distanceMeters'] as num?)?.toDouble() ?? 0.0; - final durationSeconds = - (data['durationSeconds'] as num?)?.toInt(); + final durationSeconds = (data['durationSeconds'] as num?)?.toInt(); final warningsRaw = data['warnings'] as List? ?? []; - final geometry = ValhallaPolylineDecoder.decode(encodedPolyline); - final straightLineDist = - MotorSignalRoute.calculateTotalDistanceMeters(ordered); + final geometry = encodedPolylines != null && encodedPolylines.isNotEmpty + ? encodedPolylines + .expand(ValhallaPolylineDecoder.decode) + .toList(growable: false) + : ValhallaPolylineDecoder.decode(encodedPolyline); + final straightLineDist = MotorSignalRoute.calculateTotalDistanceMeters( + ordered, + ); return RouteCalculationResult( straightLineDistanceMeters: straightLineDist, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f88f581..527bde4 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,6 +14,7 @@ import flutter_tts import geolocator_apple import google_sign_in_ios import in_app_purchase_storekit +import intentcall_platform import package_info_plus import share_plus import shared_preferences_foundation @@ -30,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) + IntentCallPlatformPlugin.register(with: registry.registrar(forPlugin: "IntentCallPlatformPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 5de8e93..e3600b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -297,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + dart_mcp: + dependency: transitive + description: + name: dart_mcp + sha256: "852b51da915d679be8d051e2e3b6c68ed19fe685f1c676bb35272a2d73ceac7a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" dart_style: dependency: transitive description: @@ -483,6 +491,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + flutter_mcp_toolkit_core: + dependency: transitive + description: + name: flutter_mcp_toolkit_core + sha256: fc61b977983b0615b84c27404db86c18bb703af519867b51fb72136f95682148 + url: "https://pub.dev" + source: hosted + version: "5.1.0" flutter_native_splash: dependency: "direct dev" description: @@ -573,6 +589,14 @@ packages: description: flutter source: sdk version: "0.0.0" + from_json_to_json: + dependency: transitive + description: + name: from_json_to_json + sha256: a29219df65cd20b1b17be8141ee51b3103d1cd95b192b7512139f3ae44775f49 + url: "https://pub.dev" + source: hosted + version: "0.5.0" geoclue: dependency: transitive description: @@ -853,6 +877,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.11+2" + intentcall_core: + dependency: transitive + description: + name: intentcall_core + sha256: "10363edd11dab107d83d5126decec03d07ab6bf9fa4685a62f9f9d9c0af13cea" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + intentcall_platform: + dependency: transitive + description: + name: intentcall_platform + sha256: a14d533b5ab2413c67ce2f943b56ab2a335a1a46b88a00ae62d72fafc6cacf50 + url: "https://pub.dev" + source: hosted + version: "0.6.0" + intentcall_schema: + dependency: transitive + description: + name: intentcall_schema + sha256: "7fd95e20f8d32f5ffe6c9cd4058d3fbf513c52dff69a63d9c4c8f4bf2810f842" + url: "https://pub.dev" + source: hosted + version: "0.6.0" intl: dependency: "direct main" description: @@ -869,6 +917,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + is_dart_empty_or_not: + dependency: transitive + description: + name: is_dart_empty_or_not + sha256: "1454632c2b961175d4c2807310713cbcbd054a51e63c545dc86c3eb9b2b061b0" + url: "https://pub.dev" + source: hosted + version: "0.4.0" jni: dependency: transitive description: @@ -901,6 +957,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" + url: "https://pub.dev" + source: hosted + version: "4.1.0" latlong2: dependency: "direct main" description: @@ -997,6 +1061,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + mcp_toolkit: + dependency: "direct main" + description: + name: mcp_toolkit + sha256: "75713760da19ef3b0b370e572d7714d51346f2411221ecce86fc5555918c0f2b" + url: "https://pub.dev" + source: hosted + version: "5.1.0" meta: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fdba60e..5dfc41d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: flutter_secure_storage: ^10.2.1 in_app_purchase: 3.3.0 marionette_flutter: ^0.6.0 + mcp_toolkit: ^5.1.0 dev_dependencies: flutter_test: diff --git a/server/waze_relay/routes/routing.js b/server/waze_relay/routes/routing.js index 6e89c77..a0a2620 100644 --- a/server/waze_relay/routes/routing.js +++ b/server/waze_relay/routes/routing.js @@ -141,7 +141,11 @@ router.post('/calculate', requireAppCheck, requireAuth, routeLimiter, async (req res.json({ provider: 'valhalla_stadia', - encodedPolyline: polylineParts.join(''), + // Each leg is its own precision-6 polyline. Do not concatenate encoded + // strings because the next leg would inherit the prior leg's deltas. + encodedPolylines: polylineParts, + // Preserve the legacy field for single-leg clients. + encodedPolyline: polylineParts.length === 1 ? polylineParts[0] : '', distanceMeters: Math.round(totalDistanceMeters * 10) / 10, durationSeconds: totalDurationSeconds, warnings: Array.isArray(data.trip.warnings) ? data.trip.warnings : [], diff --git a/server/waze_relay/test/routing_and_places.test.js b/server/waze_relay/test/routing_and_places.test.js index 4da4c21..f823849 100644 --- a/server/waze_relay/test/routing_and_places.test.js +++ b/server/waze_relay/test/routing_and_places.test.js @@ -102,6 +102,7 @@ describe('Routing Input Hardening & Validations', () => { assert.strictEqual(responseData?.distanceMeters, 15500); assert.strictEqual(responseData?.durationSeconds, 1200); assert.strictEqual(responseData?.encodedPolyline, 'test_encoded_polyline'); + assert.deepStrictEqual(responseData?.encodedPolylines, ['test_encoded_polyline']); // Verify API key is NOT leaked in client response assert.strictEqual(JSON.stringify(responseData).includes('test-secret-key-123'), false); } finally { diff --git a/test/address_autocomplete_field_test.dart b/test/address_autocomplete_field_test.dart index 472732f..333a714 100644 --- a/test/address_autocomplete_field_test.dart +++ b/test/address_autocomplete_field_test.dart @@ -26,9 +26,7 @@ void main() { await tester.pumpWidget( ProviderScope( - overrides: [ - activeRouteCalculationProvider.overrideWithValue(fake), - ], + overrides: [activeRouteCalculationProvider.overrideWithValue(fake)], child: MaterialApp( home: Scaffold( body: AddressAutocompleteField( @@ -70,6 +68,7 @@ void main() { expect(resolved!.displayName, 'Dahlonega Square'); expect(resolved!.latitude, 34.5327); expect(resolved!.longitude, -83.9849); + expect(find.text('1 Public Sq, Dahlonega, GA'), findsOneWidget); expect(find.byIcon(Icons.check_circle), findsWidgets); }); @@ -90,9 +89,7 @@ void main() { await tester.pumpWidget( ProviderScope( - overrides: [ - activeRouteCalculationProvider.overrideWithValue(fake), - ], + overrides: [activeRouteCalculationProvider.overrideWithValue(fake)], child: MaterialApp( home: Scaffold( body: AddressAutocompleteField( @@ -120,16 +117,14 @@ void main() { expect(resolved, isNull); }); - testWidgets('displays no-results state when search yields no matches', (tester) async { - final fake = FakeRouteProvider( - customSearchResults: const [], - ); + testWidgets('displays no-results state when search yields no matches', ( + tester, + ) async { + final fake = FakeRouteProvider(customSearchResults: const []); await tester.pumpWidget( ProviderScope( - overrides: [ - activeRouteCalculationProvider.overrideWithValue(fake), - ], + overrides: [activeRouteCalculationProvider.overrideWithValue(fake)], child: MaterialApp( home: Scaffold( body: AddressAutocompleteField( @@ -155,9 +150,7 @@ void main() { await tester.pumpWidget( ProviderScope( - overrides: [ - activeRouteCalculationProvider.overrideWithValue(fake), - ], + overrides: [activeRouteCalculationProvider.overrideWithValue(fake)], child: MaterialApp( home: Scaffold( body: AddressAutocompleteField( @@ -179,15 +172,15 @@ void main() { expect(find.text('RETRY'), findsOneWidget); }); - testWidgets('clearing text resets state and invalidates resolution', (tester) async { + testWidgets('clearing text resets state and invalidates resolution', ( + tester, + ) async { final fake = FakeRouteProvider(); ResolvedPlace? resolved; await tester.pumpWidget( ProviderScope( - overrides: [ - activeRouteCalculationProvider.overrideWithValue(fake), - ], + overrides: [activeRouteCalculationProvider.overrideWithValue(fake)], child: MaterialApp( home: Scaffold( body: AddressAutocompleteField( @@ -217,54 +210,55 @@ void main() { expect(find.text(''), findsOneWidget); }); - testWidgets('resolves place via provider if coordinates are missing in search result', (tester) async { - final fake = FakeRouteProvider( - customSearchResults: [ - const PlaceSearchResult( - providerId: 'need_coords_id', - primaryText: 'Neels Gap Store', - secondaryText: 'Blairsville, GA', - // No coordinates - ), - ], - customResolvedPlace: const ResolvedPlace( - providerId: 'need_coords_id', - displayName: 'Neels Gap Store', - latitude: 34.8698, - longitude: -83.9182, - ), - ); - - ResolvedPlace? resolved; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - activeRouteCalculationProvider.overrideWithValue(fake), + testWidgets( + 'resolves place via provider if coordinates are missing in search result', + (tester) async { + final fake = FakeRouteProvider( + customSearchResults: [ + const PlaceSearchResult( + providerId: 'need_coords_id', + primaryText: 'Neels Gap Store', + secondaryText: 'Blairsville, GA', + // No coordinates + ), ], - child: MaterialApp( - home: Scaffold( - body: AddressAutocompleteField( - labelText: 'Destination', - onPlaceResolved: (p) => resolved = p, + customResolvedPlace: const ResolvedPlace( + providerId: 'need_coords_id', + displayName: 'Neels Gap Store', + latitude: 34.8698, + longitude: -83.9182, + ), + ); + + ResolvedPlace? resolved; + + await tester.pumpWidget( + ProviderScope( + overrides: [activeRouteCalculationProvider.overrideWithValue(fake)], + child: MaterialApp( + home: Scaffold( + body: AddressAutocompleteField( + labelText: 'Destination', + onPlaceResolved: (p) => resolved = p, + ), ), ), ), - ), - ); + ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Neels'); - await tester.pump(const Duration(milliseconds: 400)); - await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Neels'); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); - await tester.tap(find.text('Neels Gap Store')); - await tester.pumpAndSettle(); + await tester.tap(find.text('Neels Gap Store')); + await tester.pumpAndSettle(); - expect(resolved, isNotNull); - expect(resolved!.latitude, 34.8698); - expect(resolved!.longitude, -83.9182); - }); + expect(resolved, isNotNull); + expect(resolved!.latitude, 34.8698); + expect(resolved!.longitude, -83.9182); + }, + ); }); } diff --git a/test/canonical_dashboard_responsive_test.dart b/test/canonical_dashboard_responsive_test.dart index 935729e..cb50d7d 100644 --- a/test/canonical_dashboard_responsive_test.dart +++ b/test/canonical_dashboard_responsive_test.dart @@ -154,6 +154,7 @@ void main() { expect(find.byType(GForceIndicator), findsOneWidget); expect(find.byType(LeanAngleIndicator), findsOneWidget); + expect(find.text('PERFORMANCE LAUNCH'), findsOneWidget); }); testWidgets('Minimal Mode displays Speedometer and minimal controls', ( diff --git a/test/valhalla_route_provider_test.dart b/test/valhalla_route_provider_test.dart index fe40445..cfba5aa 100644 --- a/test/valhalla_route_provider_test.dart +++ b/test/valhalla_route_provider_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:motorcycle_dash/services/routes/route_providers.dart'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -10,38 +10,43 @@ import 'package:motorcycle_dash/services/routes/valhalla_route_provider.dart'; void main() { group('ValhallaRouteProvider Tests', () { - test('throws authenticationRequired when token provider returns null', () async { - final provider = ValhallaRouteProvider( - backendUrl: 'http://localhost:3000', - tokenProvider: () async => null, - ); + test( + 'throws authenticationRequired when token provider returns null', + () async { + final provider = ValhallaRouteProvider( + backendUrl: 'http://localhost:3000', + tokenProvider: () async => null, + ); - final stops = [ - const RouteStop( - id: 's1', - displayName: 'Start', - latitude: 37.77, - longitude: -122.42, - order: 0, - ), - const RouteStop( - id: 's2', - displayName: 'End', - latitude: 37.80, - longitude: -122.27, - order: 1, - ), - ]; + final stops = [ + const RouteStop( + id: 's1', + displayName: 'Start', + latitude: 37.77, + longitude: -122.42, + order: 0, + ), + const RouteStop( + id: 's2', + displayName: 'End', + latitude: 37.80, + longitude: -122.27, + order: 1, + ), + ]; - expect( - () => provider.calculateRoute(stops), - throwsA(isA().having( - (e) => e.code, - 'code', - 'authenticationRequired', - )), - ); - }); + expect( + () => provider.calculateRoute(stops), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'authenticationRequired', + ), + ), + ); + }, + ); test('validates minimum 2 stops', () async { final provider = ValhallaRouteProvider( @@ -60,11 +65,13 @@ void main() { expect( () => provider.calculateRoute(stops), - throwsA(isA().having( - (e) => e.code, - 'code', - 'invalidRequest', - )), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'invalidRequest', + ), + ), ); }); @@ -86,31 +93,86 @@ void main() { expect( () => provider.calculateRoute(stops), - throwsA(isA().having( - (e) => e.code, - 'code', - 'invalidRequest', - )), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'invalidRequest', + ), + ), ); }); - test('calculates 2-stop route and parses normalized result with precision 6 geometry', () async { + test( + 'calculates 2-stop route and parses normalized result with precision 6 geometry', + () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/api/routes/calculate'); + expect(request.headers['authorization'], 'Bearer valid-test-token'); + + final payload = jsonDecode(request.body) as Map; + expect((payload['locations'] as List).length, 2); + + // Return fixture for SF to Oakland + return http.Response( + jsonEncode({ + 'provider': 'valhalla_stadia', + 'encodedPolyline': 'gbr`gAnk{nhFwrx@om`H', + 'distanceMeters': 18500.5, + 'durationSeconds': 1320, + 'warnings': ['Motorcycle route preview'], + 'attribution': '© Stadia Maps', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final provider = ValhallaRouteProvider( + client: mockClient, + tokenProvider: () async => 'valid-test-token', + ); + + final stops = [ + const RouteStop( + id: 's1', + displayName: 'SF', + latitude: 37.7749, + longitude: -122.4194, + order: 0, + ), + const RouteStop( + id: 's2', + displayName: 'Oakland', + latitude: 37.8044, + longitude: -122.2712, + order: 1, + ), + ]; + + final result = await provider.calculateRoute(stops); + + expect(result.isStraightLine, false); + expect(result.estimatedDistanceMeters, 18500.5); + expect(result.estimatedDurationSeconds, 1320); + expect(result.geometry.length, 2); + expect(result.geometry[0].latitude, closeTo(37.7749, 1e-4)); + expect(result.geometry[1].latitude, closeTo(37.8044, 1e-4)); + expect(result.warnings, contains('Motorcycle route preview')); + }, + ); + + test('decodes each route leg independently', () async { final mockClient = MockClient((request) async { - expect(request.url.path, '/api/routes/calculate'); - expect(request.headers['authorization'], 'Bearer valid-test-token'); - - final payload = jsonDecode(request.body) as Map; - expect((payload['locations'] as List).length, 2); - - // Return fixture for SF to Oakland return http.Response( jsonEncode({ - 'provider': 'valhalla_stadia', - 'encodedPolyline': 'gbr`gAnk{nhFwrx@om`H', - 'distanceMeters': 18500.5, - 'durationSeconds': 1320, - 'warnings': ['Motorcycle route preview'], - 'attribution': '© Stadia Maps', + 'encodedPolylines': [ + 'gbr`gAnk{nhFwrx@om`H', + 'gbr`gAnk{nhFwrx@om`H', + ], + 'distanceMeters': 1000, + 'durationSeconds': 60, + 'warnings': [], }), 200, headers: {'content-type': 'application/json'}, @@ -122,79 +184,82 @@ void main() { tokenProvider: () async => 'valid-test-token', ); - final stops = [ + final result = await provider.calculateRoute([ const RouteStop( - id: 's1', - displayName: 'SF', - latitude: 37.7749, - longitude: -122.4194, + id: '1', + displayName: '1', + latitude: 37, + longitude: -122, order: 0, ), const RouteStop( - id: 's2', - displayName: 'Oakland', - latitude: 37.8044, - longitude: -122.2712, + id: '2', + displayName: '2', + latitude: 37.1, + longitude: -122, order: 1, ), - ]; + const RouteStop( + id: '3', + displayName: '3', + latitude: 37.2, + longitude: -122, + order: 2, + ), + ]); - final result = await provider.calculateRoute(stops); - - expect(result.isStraightLine, false); - expect(result.estimatedDistanceMeters, 18500.5); - expect(result.estimatedDurationSeconds, 1320); - expect(result.geometry.length, 2); - expect(result.geometry[0].latitude, closeTo(37.7749, 1e-4)); - expect(result.geometry[1].latitude, closeTo(37.8044, 1e-4)); - expect(result.warnings, contains('Motorcycle route preview')); + expect(result.geometry.length, 4); }); - test('searches places and parses coordinates from search results', () async { - final mockClient = MockClient((request) async { - expect(request.url.path, '/api/places/search'); - expect(request.url.queryParameters['query'], 'Alice Restaurant'); + test( + 'searches places and parses coordinates from search results', + () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/api/places/search'); + expect(request.url.queryParameters['query'], 'Alice Restaurant'); - return http.Response( - jsonEncode({ - 'results': [ - { - 'providerId': 'place_123', - 'primaryText': "Alice's Restaurant", - 'secondaryText': 'Woodside, CA', - 'formattedAddress': "Alice's Restaurant, Skyline Blvd, Woodside, CA", - 'latitude': 37.3868, - 'longitude': -122.2644, - } - ], - 'attribution': 'Powered by Stadia Maps', - }), - 200, - headers: {'content-type': 'application/json'}, + return http.Response( + jsonEncode({ + 'results': [ + { + 'providerId': 'place_123', + 'primaryText': "Alice's Restaurant", + 'secondaryText': 'Woodside, CA', + 'formattedAddress': + "Alice's Restaurant, Skyline Blvd, Woodside, CA", + 'latitude': 37.3868, + 'longitude': -122.2644, + }, + ], + 'attribution': 'Powered by Stadia Maps', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final provider = ValhallaRouteProvider( + client: mockClient, + tokenProvider: () async => 'valid-test-token', ); - }); - final provider = ValhallaRouteProvider( - client: mockClient, - tokenProvider: () async => 'valid-test-token', - ); + final results = await provider.searchPlaces('Alice Restaurant'); - final results = await provider.searchPlaces('Alice Restaurant'); + expect(results.length, 1); + final place = results.first; + expect(place.providerId, 'place_123'); + expect(place.primaryText, "Alice's Restaurant"); + expect(place.hasCoordinates, true); + expect(place.latitude, 37.3868); + expect(place.longitude, -122.2644); - expect(results.length, 1); - final place = results.first; - expect(place.providerId, 'place_123'); - expect(place.primaryText, "Alice's Restaurant"); - expect(place.hasCoordinates, true); - expect(place.latitude, 37.3868); - expect(place.longitude, -122.2644); - - // Verify immediate resolution without second network call - final resolved = place.toResolvedPlace(); - expect(resolved, isNotNull); - expect(resolved!.latitude, 37.3868); - expect(resolved.displayName, "Alice's Restaurant"); - }); + // Verify immediate resolution without second network call + final resolved = place.toResolvedPlace(); + expect(resolved, isNotNull); + expect(resolved!.latitude, 37.3868); + expect(resolved.displayName, "Alice's Restaurant"); + }, + ); test('resolves place details by placeId', () async { final mockClient = MockClient((request) async { @@ -246,29 +311,46 @@ void main() { ); final stops = [ - const RouteStop(id: '1', displayName: '1', latitude: 0, longitude: 0, order: 0), - const RouteStop(id: '2', displayName: '2', latitude: 1, longitude: 1, order: 1), + const RouteStop( + id: '1', + displayName: '1', + latitude: 0, + longitude: 0, + order: 0, + ), + const RouteStop( + id: '2', + displayName: '2', + latitude: 1, + longitude: 1, + order: 1, + ), ]; expect( () => provider.calculateRoute(stops), - throwsA(isA().having( - (e) => e.code, - 'code', - 'routeNotFound', - )), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'routeNotFound', + ), + ), ); }); test('routing request after bootstrap sends Bearer token', () async { var tokenProviderCalled = false; final mockClient = MockClient((request) async { expect(request.headers['authorization'], 'Bearer my-bootstrap-token'); - return http.Response(jsonEncode({ - 'encodedPolyline': 'w~j_~A_{n~P', - 'distanceMeters': 1234.5, - 'durationSeconds': 600, - 'warnings': [] - }), 200); + return http.Response( + jsonEncode({ + 'encodedPolyline': 'w~j_~A_{n~P', + 'distanceMeters': 1234.5, + 'durationSeconds': 600, + 'warnings': [], + }), + 200, + ); }); final provider = ValhallaRouteProvider( @@ -280,8 +362,20 @@ void main() { ); final stops = [ - const RouteStop(id: '1', displayName: '1', latitude: 10, longitude: 10, order: 0), - const RouteStop(id: '2', displayName: '2', latitude: 20, longitude: 20, order: 1), + const RouteStop( + id: '1', + displayName: '1', + latitude: 10, + longitude: 10, + order: 0, + ), + const RouteStop( + id: '2', + displayName: '2', + latitude: 20, + longitude: 20, + order: 1, + ), ]; await provider.calculateRoute(stops); @@ -291,11 +385,14 @@ void main() { test('search request after bootstrap sends Bearer token', () async { var tokenProviderCalled = false; final mockClient = MockClient((request) async { - expect(request.headers['authorization'], 'Bearer search-bootstrap-token'); - return http.Response(jsonEncode({ - 'results': [], - 'attribution': 'Test' - }), 200); + expect( + request.headers['authorization'], + 'Bearer search-bootstrap-token', + ); + return http.Response( + jsonEncode({'results': [], 'attribution': 'Test'}), + 200, + ); }); final provider = ValhallaRouteProvider( @@ -313,13 +410,19 @@ void main() { test('resolve request after bootstrap sends Bearer token', () async { var tokenProviderCalled = false; final mockClient = MockClient((request) async { - expect(request.headers['authorization'], 'Bearer resolve-bootstrap-token'); - return http.Response(jsonEncode({ - 'providerId': 'place_123', - 'displayName': 'A Place', - 'latitude': 10.0, - 'longitude': 10.0 - }), 200); + expect( + request.headers['authorization'], + 'Bearer resolve-bootstrap-token', + ); + return http.Response( + jsonEncode({ + 'providerId': 'place_123', + 'displayName': 'A Place', + 'latitude': 10.0, + 'longitude': 10.0, + }), + 200, + ); }); final provider = ValhallaRouteProvider( @@ -344,40 +447,63 @@ void main() { expect(customProvider.backendUrl, 'http://custom-host:8080'); }); - test('activeRouteCalculationProvider resolves to ValhallaRouteProvider by default', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - final activeProvider = container.read(activeRouteCalculationProvider); - expect(activeProvider, isA()); - expect(activeProvider.name, 'Stadia Maps (Valhalla)'); - }); + test( + 'activeRouteCalculationProvider resolves to ValhallaRouteProvider by default', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final activeProvider = container.read(activeRouteCalculationProvider); + expect(activeProvider, isA()); + expect(activeProvider.name, 'Stadia Maps (Valhalla)'); + }, + ); - test('provider errors remain explicit and do not silently fall back to straight line', () async { - final mockClient = MockClient((request) async { - return http.Response(jsonEncode({ - 'error': 'providerUnavailable', - 'message': 'Routing server unavailable' - }), 503); - }); + test( + 'provider errors remain explicit and do not silently fall back to straight line', + () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'error': 'providerUnavailable', + 'message': 'Routing server unavailable', + }), + 503, + ); + }); - final provider = ValhallaRouteProvider( - client: mockClient, - tokenProvider: () async => 'token', - ); + final provider = ValhallaRouteProvider( + client: mockClient, + tokenProvider: () async => 'token', + ); - final stops = [ - const RouteStop(id: '1', displayName: '1', latitude: 10, longitude: 10, order: 0), - const RouteStop(id: '2', displayName: '2', latitude: 20, longitude: 20, order: 1), - ]; + final stops = [ + const RouteStop( + id: '1', + displayName: '1', + latitude: 10, + longitude: 10, + order: 0, + ), + const RouteStop( + id: '2', + displayName: '2', + latitude: 20, + longitude: 20, + order: 1, + ), + ]; - expect( - () => provider.calculateRoute(stops), - throwsA(isA().having( - (e) => e.code, - 'code', - 'providerUnavailable', - )), - ); - }); + expect( + () => provider.calculateRoute(stops), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'providerUnavailable', + ), + ), + ); + }, + ); }); } diff --git a/website/.env.example b/website/.env.example new file mode 100644 index 0000000..22b0b12 --- /dev/null +++ b/website/.env.example @@ -0,0 +1 @@ +MOTORSIGNAL_API_BASE_URL=https://api.motorsignal.app diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..3854550 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,4 @@ +.next/ +node_modules/ +.env* +!.env.example diff --git a/website/AGENTS.md b/website/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/website/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/website/CLAUDE.md b/website/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/website/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx new file mode 100644 index 0000000..221081b --- /dev/null +++ b/website/app/account/page.tsx @@ -0,0 +1,32 @@ +import Link from 'next/link'; +import { cookies } from 'next/headers'; + +type CurrentUser = { id: string; email: string; email_verified: boolean }; +type Vehicle = { id: string; nickname: string; make: string; model: string; year?: number | null }; +type Ride = { id: string; started_at: string; distance_miles?: number | null; top_speed_mph?: number | null; status: string }; +type Subscription = { plan: string; status: string }; + +async function loadAccount(): Promise<{ user: CurrentUser; vehicles: Vehicle[]; rides: Ride[]; subscription: Subscription } | null> { + const token = (await cookies()).get('motorsignal_access_token')?.value; + if (!token) return null; + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + try { + const response = await fetch(`${baseUrl}/api/v1/me`, { headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }); + if (!response.ok) return null; + const user = await response.json() as CurrentUser; + const [vehiclesResponse, ridesResponse, subscriptionResponse] = await Promise.all([ + fetch(`${baseUrl}/api/v1/vehicles`, { headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }), + fetch(`${baseUrl}/api/v1/rides?page=1&page_size=5`, { headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }), + fetch(`${baseUrl}/api/v1/me/subscription`, { headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }), + ]); + return { user, vehicles: vehiclesResponse.ok ? await vehiclesResponse.json() as Vehicle[] : [], rides: ridesResponse.ok ? (await ridesResponse.json()).items as Ride[] : [], subscription: subscriptionResponse.ok ? await subscriptionResponse.json() as Subscription : { plan: 'free', status: 'unknown' } }; + } catch { + return null; + } +} + +export default async function AccountPage() { + const account = await loadAccount(); + if (!account) return
Account portal

Sign in to your rider space.

Manage your MotorSignal account, security, vehicles, and ride history from one place.

Sign inCreate account
; + return
Account portal

Welcome back.

{account.user.email}

Profile

Account status: Active

Email verification: {account.user.email_verified ? 'Verified' : 'Pending'}

Plan

{account.subscription.plan} · {account.subscription.status}

Vehicles

{account.vehicles.length ? account.vehicles.map((vehicle) =>

{vehicle.nickname} · {vehicle.year ?? ''} {vehicle.make} {vehicle.model}

) :

No vehicles yet.

}

Recent rides

{account.rides.length ? account.rides.map((ride) =>

{new Date(ride.started_at).toLocaleDateString()} · {ride.distance_miles ?? 0} mi · {ride.status}

) :

No rides yet.

}
; +} diff --git a/website/app/admin/page.tsx b/website/app/admin/page.tsx new file mode 100644 index 0000000..6b9c3c7 --- /dev/null +++ b/website/app/admin/page.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link'; +import { cookies } from 'next/headers'; +import { AdminConsole } from '../../components/admin-console'; + +type AuditEvent = { id: string; event_type: string; actor_user_id: string | null; target_user_id: string | null; created_at: string; metadata: Record | null }; + +async function loadAuditEvents(): Promise<{ status: number; events: AuditEvent[] }> { + const token = (await cookies()).get('motorsignal_access_token')?.value; + if (!token) return { status: 401, events: [] }; + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + try { + const response = await fetch(`${baseUrl}/api/v1/admin/audit-events`, { headers: { authorization: `Bearer ${token}` }, cache: 'no-store' }); + if (!response.ok) return { status: response.status, events: [] }; + return { status: response.status, events: await response.json() as AuditEvent[] }; + } catch { + return { status: 503, events: [] }; + } +} + +export default async function AdminPage() { + const result = await loadAuditEvents(); + if (result.status === 401) return
Admin console

Sign in required.

Admin tools require an authenticated MotorSignal session.

Sign in
; + if (result.status === 403) return
Admin console

Access restricted.

Your account does not have support or admin permissions.

Back to account
; + if (result.status !== 200) return
Admin console

Console unavailable.

Audit data could not be loaded. Try again when the API is available.

; + return
Admin console

Operations.

Manage users, vehicles, rides, and review recent account activity.

Recent audit events

{result.events.length === 0 ?

No events recorded.

: result.events.map((event) =>
{event.event_type}

{event.actor_user_id ?? 'System'} {event.target_user_id ? `targeted ${event.target_user_id}` : ''}

)}
; +} diff --git a/website/app/api/admin/[...path]/route.ts b/website/app/api/admin/[...path]/route.ts new file mode 100644 index 0000000..69ccc0d --- /dev/null +++ b/website/app/api/admin/[...path]/route.ts @@ -0,0 +1,20 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +const resources = new Set(['users', 'vehicles', 'rides']); + +async function proxy(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const { path } = await context.params; + if (!path.length || !resources.has(path[0])) return NextResponse.json({ detail: 'Unknown admin resource.' }, { status: 404 }); + const token = (await cookies()).get('motorsignal_access_token')?.value; + if (!token) return NextResponse.json({ detail: 'Authentication required.' }, { status: 401 }); + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + const body = request.method === 'GET' || request.method === 'DELETE' ? undefined : await request.text(); + const response = await fetch(`${baseUrl}/api/v1/admin/${path.join('/')}${request.nextUrl.search}`, { method: request.method, headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body, cache: 'no-store' }); + return new NextResponse(await response.text(), { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json' } }); +} + +export const GET = proxy; +export const POST = proxy; +export const PATCH = proxy; +export const DELETE = proxy; diff --git a/website/app/api/auth/[action]/route.ts b/website/app/api/auth/[action]/route.ts new file mode 100644 index 0000000..84ccc10 --- /dev/null +++ b/website/app/api/auth/[action]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const actions = new Set(['login', 'register', 'refresh', 'logout', 'verify-email-request', 'verify-email', 'password-reset-request', 'password-reset']); + +export async function POST(request: NextRequest, context: { params: Promise<{ action: string }> }) { + const { action } = await context.params; + if (!actions.has(action)) return NextResponse.json({ detail: 'Unknown authentication action.' }, { status: 404 }); + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + const endpoint = action === 'verify-email-request' ? 'verify-email/request' + : action === 'password-reset-request' ? 'password-reset/request' + : action; + const upstream = await fetch(`${baseUrl}/api/v1/auth/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(await request.json()), + cache: 'no-store', + }); + const body = await upstream.text(); + const response = new NextResponse(body, { status: upstream.status, headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' } }); + if (action === 'logout') { + response.cookies.delete('motorsignal_access_token'); + response.cookies.delete('motorsignal_refresh_token'); + } else if (upstream.ok) { + let payload: { access_token?: string; refresh_token?: string } = {}; + try { + payload = JSON.parse(body) as { access_token?: string; refresh_token?: string }; + } catch { + return response; + } + const cookieOptions = { httpOnly: true, secure: true, sameSite: 'lax' as const, path: '/', maxAge: 60 * 60 * 24 * 30 }; + if (payload.access_token) response.cookies.set('motorsignal_access_token', payload.access_token, cookieOptions); + if (payload.refresh_token) response.cookies.set('motorsignal_refresh_token', payload.refresh_token, cookieOptions); + } + return response; +} diff --git a/website/app/download/page.tsx b/website/app/download/page.tsx new file mode 100644 index 0000000..e94e3fc --- /dev/null +++ b/website/app/download/page.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link'; + +export default function DownloadPage() { + return
+ Ride with more signal +

MotorSignal for your motorcycle.

+

Install the mobile app to bring live telemetry, ride recording, route context, and performance history to your cockpit.

+
+

Android

The Android build is available for device testing and release distribution.

Request the latest build
+

Already installed?

Sign in to manage your account and keep your ride data connected.

Open account
+
+
; +} diff --git a/website/app/features/page.tsx b/website/app/features/page.tsx new file mode 100644 index 0000000..d22bcab --- /dev/null +++ b/website/app/features/page.tsx @@ -0,0 +1 @@ +export default function FeaturesPage() { return
The platform

Every signal has a job.

MotorSignal brings telemetry, route context, performance analysis, group rides, and secure cloud sync into one rider-focused system.

Telemetry

Live speed, GPS, lean angle, acceleration, braking, and ride state.

Performance

Launch timing and ride metrics stay associated with the ride that produced them.

Community

Plan routes, discover clubs, and ride with people who share your road.

; } diff --git a/website/app/forgot-password/page.tsx b/website/app/forgot-password/page.tsx new file mode 100644 index 0000000..abd8510 --- /dev/null +++ b/website/app/forgot-password/page.tsx @@ -0,0 +1,3 @@ +import { RecoveryForm } from '../../components/recovery-form'; + +export default function ForgotPasswordPage() { return
Account security

Reset access safely.

Request a short-lived reset link, then choose a new password.

; } diff --git a/website/app/globals.css b/website/app/globals.css new file mode 100644 index 0000000..fb489b6 --- /dev/null +++ b/website/app/globals.css @@ -0,0 +1,68 @@ +:root { --ink: #f4f7fa; --muted: #9ca9b6; --bg: #0a0f14; --panel: #121a22; --line: #24313c; --blue: #1683ff; --green: #2cc979; } +* { box-sizing: border-box; } +html { background: var(--bg); } +body { margin: 0; background: radial-gradient(circle at 70% -20%, #17324b 0, var(--bg) 42%); color: var(--ink); font-family: Arial, Helvetica, sans-serif; min-height: 100vh; } +a { color: inherit; text-decoration: none; } +.site-header { align-items: center; border-bottom: 1px solid var(--line); display: flex; gap: 28px; justify-content: space-between; margin: 0 auto; max-width: 1240px; padding: 20px 28px; } +.brand { align-items: center; display: inline-flex; font-size: 19px; font-weight: 800; gap: 10px; letter-spacing: .02em; } +.brand-mark { align-items: center; background: var(--blue); border-radius: 6px; display: inline-flex; height: 30px; justify-content: center; width: 30px; } +nav { display: flex; gap: 24px; margin-left: auto; } +nav a, .quiet-link { color: var(--muted); font-size: 14px; } +nav a:hover, .quiet-link:hover { color: var(--ink); } +.header-actions { align-items: center; display: flex; gap: 16px; } +.button { background: var(--blue); border: 0; border-radius: 6px; color: white; cursor: pointer; display: inline-flex; font-size: 15px; font-weight: 700; justify-content: center; padding: 13px 18px; } +.button:hover { filter: brightness(1.12); } +.button:disabled { cursor: wait; opacity: .6; } +.button-small { font-size: 13px; padding: 9px 13px; } +main { margin: 0 auto; max-width: 1240px; padding: 72px 28px 100px; } +.hero { max-width: 780px; } +.eyebrow { color: var(--green); font-size: 12px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } +h1 { font-size: clamp(42px, 7vw, 82px); line-height: .98; margin: 18px 0; max-width: 850px; } +h2 { font-size: 32px; margin: 0 0 14px; } +p { color: var(--muted); line-height: 1.65; } +.hero p { font-size: 18px; max-width: 650px; } +.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; } +.button-secondary { background: transparent; border: 1px solid var(--line); } +.section-grid { display: grid; gap: 16px; grid-template-columns: repeat(3, 1fr); margin-top: 92px; } +.section-grid-two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 42px; } +.panel { background: color-mix(in srgb, var(--panel) 90%, transparent); border: 1px solid var(--line); border-radius: 8px; padding: 24px; } +.panel h3 { margin: 0 0 8px; } +.panel h2 { font-size: 24px; } +.panel p { font-size: 14px; margin: 0; } +.panel .button { margin-top: 20px; } +.content-page { max-width: 900px; margin: auto; } +.content-page > p { font-size: 18px; max-width: 700px; } +.account-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 42px; } +.legal-page { max-width: 760px; } +.legal-page h2 { font-size: 22px; margin-top: 36px; } +.site-footer { border-top: 1px solid var(--line); color: var(--muted); display: flex; justify-content: space-between; margin: 0 auto; max-width: 1240px; padding: 24px 28px 40px; font-size: 13px; } +.auth-page { display: grid; gap: 28px; grid-template-columns: minmax(0, 1fr) minmax(320px, 430px); margin: auto; max-width: 980px; align-items: center; } +.auth-card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 28px; } +.auth-form { display: grid; gap: 16px; margin-top: 24px; } +label { color: var(--muted); display: grid; font-size: 13px; gap: 7px; } +input { background: #0c131a; border: 1px solid var(--line); border-radius: 5px; color: var(--ink); font: inherit; padding: 12px; } +input:focus { border-color: var(--blue); outline: 2px solid color-mix(in srgb, var(--blue) 35%, transparent); } +.form-error { color: #ff8e8e; font-size: 13px; margin: 0; } +.muted-link { color: var(--blue); font-size: 14px; } +.recovery-stack { display: grid; gap: 8px; } +.form-divider { border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; margin-top: 12px; padding-top: 20px; } +.form-message { color: var(--green); font-size: 13px; } +.audit-list { display: grid; gap: 10px; margin-top: 32px; } +.audit-row { align-items: center; display: flex; gap: 20px; justify-content: space-between; padding: 18px 20px; } +.audit-row p { font-size: 13px; margin: 5px 0 0; } +.audit-row time { color: var(--muted); font-size: 12px; white-space: nowrap; } +.admin-console { margin-top: 32px; } +.admin-tabs { border-bottom: 1px solid var(--line); display: flex; gap: 8px; } +.admin-tab { background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--muted); cursor: pointer; font: inherit; padding: 12px 16px; text-transform: capitalize; } +.admin-tab.active { border-bottom-color: var(--blue); color: var(--ink); } +.admin-create-form { display: flex; flex-wrap: wrap; gap: 8px; margin: 20px 0; } +.admin-create-form input, .admin-create-form select { flex: 1 1 150px; min-width: 130px; } +.admin-table-wrap { overflow-x: auto; } +.admin-table { border-collapse: collapse; min-width: 700px; width: 100%; } +.admin-table th, .admin-table td { border-bottom: 1px solid var(--line); padding: 14px 10px; text-align: left; } +.admin-table th { color: var(--muted); font-size: 12px; text-transform: uppercase; } +.admin-table td { font-size: 13px; } +.text-button { background: transparent; border: 0; color: var(--blue); cursor: pointer; padding: 4px 0; } +.empty-state { color: var(--muted); } +.admin-audit-heading { font-size: 24px; margin-top: 64px; } +@media (max-width: 760px) { .site-header { flex-wrap: wrap; padding: 16px 18px; } nav { order: 3; width: 100%; } main { padding: 48px 18px 72px; } .section-grid, .section-grid-two, .account-grid, .auth-page { grid-template-columns: 1fr; margin-top: 56px; } h1 { font-size: 48px; } .site-footer { padding-left: 18px; padding-right: 18px; } } diff --git a/website/app/layout.tsx b/website/app/layout.tsx new file mode 100644 index 0000000..8da52c0 --- /dev/null +++ b/website/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; +import './globals.css'; +import { SiteHeader } from '../components/site-header'; + +export const metadata: Metadata = { + title: 'MotorSignal | The ride intelligence platform', + description: 'Motorcycle telemetry, ride history, and connected rider tools.', +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + +
{children}
+
+ MotorSignal + Ride smarter. Know more. Privacy · Terms +
+ + + ); +} diff --git a/website/app/login/page.tsx b/website/app/login/page.tsx new file mode 100644 index 0000000..d15bfed --- /dev/null +++ b/website/app/login/page.tsx @@ -0,0 +1,4 @@ +import Link from 'next/link'; +import { AuthForm } from '../../components/auth-form'; + +export default function LoginPage() { return
Rider account

Welcome back.

Sign in to access your MotorSignal account.

Forgot your password?
; } diff --git a/website/app/page.tsx b/website/app/page.tsx new file mode 100644 index 0000000..f6c57c8 --- /dev/null +++ b/website/app/page.tsx @@ -0,0 +1,17 @@ +import Link from 'next/link'; + +export default function HomePage() { + return <> +
+ Motorcycle telemetry, made useful +

Know what your ride is doing.

+

MotorSignal turns every ride into clear, actionable insight: live telemetry on the road, performance history after the ride, and one secure place for your account.

+
Create your accountExplore features
+
+
+

Live cockpit

Speed, lean angle, G-force, GPS status, and ride state in one rider-first dashboard.

+

Ride intelligence

Review distance, speed, performance launches, route context, and telemetry summaries after every ride.

+

Built for trust

Your account, sessions, vehicles, and ride data stay behind authenticated HTTPS APIs.

+
+ ; +} diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx new file mode 100644 index 0000000..27fd1f0 --- /dev/null +++ b/website/app/pricing/page.tsx @@ -0,0 +1 @@ +export default function PricingPage() { return
Plans

Start with the ride.

MotorSignal’s account platform is being prepared for clear free and premium access. Sign up now and we’ll keep your account ready for the next release.

MotorSignal account

Secure sign-in, device sessions, and your rider data portal.

; } diff --git a/website/app/privacy/page.tsx b/website/app/privacy/page.tsx new file mode 100644 index 0000000..46d2a6f --- /dev/null +++ b/website/app/privacy/page.tsx @@ -0,0 +1,3 @@ +export default function PrivacyPage() { + return
Your data

Privacy

MotorSignal uses account, vehicle, ride, and telemetry data to provide the service. We use authenticated transport, scoped access, and operational logs to protect the platform.

What we collect

Information you provide, device and session details, and ride data generated when you use telemetry and recording features.

How we use it

We use this information to operate the app, sync your rides, provide support, prevent abuse, and improve reliability. We do not expose private ride data through public pages.

Your choices

You can manage your account through the portal and contact support for access, correction, or deletion requests.

; +} diff --git a/website/app/register/page.tsx b/website/app/register/page.tsx new file mode 100644 index 0000000..8883d8a --- /dev/null +++ b/website/app/register/page.tsx @@ -0,0 +1,4 @@ +import Link from 'next/link'; +import { AuthForm } from '../../components/auth-form'; + +export default function RegisterPage() { return
Rider account

Bring your data with you.

Create a secure MotorSignal account for your rides, devices, and future web portal.

Already have an account? Sign in

; } diff --git a/website/app/support/page.tsx b/website/app/support/page.tsx new file mode 100644 index 0000000..4e09c33 --- /dev/null +++ b/website/app/support/page.tsx @@ -0,0 +1 @@ +export default function SupportPage() { return
Support

Help for the road ahead.

Account and product support will be available here as the MotorSignal web platform comes online.

Contact support
; } diff --git a/website/app/terms/page.tsx b/website/app/terms/page.tsx new file mode 100644 index 0000000..e7355e5 --- /dev/null +++ b/website/app/terms/page.tsx @@ -0,0 +1,3 @@ +export default function TermsPage() { + return
Service terms

Terms of service

MotorSignal provides motorcycle telemetry and ride-management tools. Use the service lawfully and keep account credentials secure.

Ride safely

MotorSignal is an assistive tool, not a substitute for attention, judgment, training, or compliance with traffic laws. Never interact with the app in a way that distracts you while riding.

Account responsibility

You are responsible for activity under your account and for the accuracy of information you submit. We may restrict abusive or unsafe use.

Service changes

Features may evolve as we improve reliability and security. Material changes will be communicated through the service when appropriate.

; +} diff --git a/website/app/verify-email/page.tsx b/website/app/verify-email/page.tsx new file mode 100644 index 0000000..90366e7 --- /dev/null +++ b/website/app/verify-email/page.tsx @@ -0,0 +1,6 @@ +import Link from 'next/link'; +import { VerifyEmailForm } from '../../components/verify-email-form'; + +export default function VerifyEmailPage() { + return
Account security

Verify your email.

Use the link in your MotorSignal email to confirm ownership of your address.

Return to sign in

Confirm address

Contact support
; +} diff --git a/website/components/admin-console.tsx b/website/components/admin-console.tsx new file mode 100644 index 0000000..623dc4c --- /dev/null +++ b/website/components/admin-console.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { FormEvent, useEffect, useState } from 'react'; + +type Resource = 'users' | 'vehicles' | 'rides'; +type Item = Record; + +async function adminRequest(path: string, init?: RequestInit) { + const response = await fetch(`/api/admin/${path}`, { ...init, headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) } }); + const body = await response.json().catch(() => null); + if (!response.ok) throw new Error(body?.detail ?? 'Admin request failed.'); + return body; +} + +export function AdminConsole() { + const [resource, setResource] = useState('users'); + const [items, setItems] = useState([]); + const [message, setMessage] = useState(''); + const [busy, setBusy] = useState(false); + + async function load(selected = resource) { + setBusy(true); setMessage(''); + try { const body = await adminRequest(`${selected}?page=1&page_size=50`); setItems(body.items ?? []); } + catch (error) { setItems([]); setMessage(error instanceof Error ? error.message : 'Unable to load admin data.'); } + finally { setBusy(false); } + } + + useEffect(() => { void load(); }, [resource]); + + async function create(event: FormEvent) { + event.preventDefault(); setBusy(true); setMessage(''); + const form = new FormData(event.currentTarget); + const payload = Object.fromEntries(form.entries()); + try { await adminRequest(resource, { method: 'POST', body: JSON.stringify(payload) }); event.currentTarget.reset(); setMessage('Created.'); await load(); } + catch (error) { setMessage(error instanceof Error ? error.message : 'Create failed.'); } + finally { setBusy(false); } + } + + async function archive(id: string) { + setBusy(true); setMessage(''); + try { await adminRequest(`${resource}/${id}`, { method: 'DELETE' }); setMessage(resource === 'rides' ? 'Ride archived.' : 'Deleted.'); await load(); } + catch (error) { setMessage(error instanceof Error ? error.message : 'Action failed.'); } + finally { setBusy(false); } + } + + return
{(['users', 'vehicles', 'rides'] as Resource[]).map((value) => )}
{resource === 'users' && <>}{resource === 'vehicles' && <>}{resource === 'rides' && <>}
{message &&

{message}

}
{resource === 'users' ? <>{items.map((item) => {resource === 'users' ? <> : resource === 'vehicles' ? <> : <>})}
EmailRoleStatus : resource === 'vehicles' ? <>NicknameMake / modelOwner : <>RideRiderDistanceStatus}
{String(item.email)}{String(item.role)}{item.is_active ? 'Active' : 'Inactive'}{String(item.nickname)}{String(item.make)} {String(item.model)}{String(item.user_id)}{String(item.id).slice(0, 8)}{String(item.user_id)}{item.distance_miles == null ? '-' : `${item.distance_miles} mi`}{String(item.status)}{item.status !== 'archived' && }
{!busy && items.length === 0 &&

No {resource} found.

}
; +} diff --git a/website/components/auth-form.tsx b/website/components/auth-form.tsx new file mode 100644 index 0000000..cff5b7b --- /dev/null +++ b/website/components/auth-form.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { FormEvent, useState } from 'react'; + +type AuthMode = 'login' | 'register'; + +export function AuthForm({ mode }: { mode: AuthMode }) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [deviceName, setDeviceName] = useState('Web browser'); + const [message, setMessage] = useState(''); + const [busy, setBusy] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setMessage(''); + try { + const response = await fetch(`/api/auth/${mode}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password, device_name: deviceName }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.detail ?? 'Authentication failed.'); + window.location.assign('/account'); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Authentication failed.'); + } finally { + setBusy(false); + } + } + + return ( +
+ + + + {message &&

{message}

} + +
+ ); +} diff --git a/website/components/recovery-form.tsx b/website/components/recovery-form.tsx new file mode 100644 index 0000000..12d305c --- /dev/null +++ b/website/components/recovery-form.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { FormEvent, useState } from 'react'; + +export function RecoveryForm() { + const [email, setEmail] = useState(''); + const [token, setToken] = useState(''); + const [password, setPassword] = useState(''); + const [message, setMessage] = useState(''); + const [busy, setBusy] = useState(false); + + async function submitRequest(event: FormEvent) { + event.preventDefault(); + setBusy(true); setMessage(''); + try { + const response = await fetch('/api/auth/password-reset-request', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email }) }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.detail ?? 'Unable to request reset.'); + setMessage('If the account exists, reset instructions have been sent.'); + } catch (error) { setMessage(error instanceof Error ? error.message : 'Unable to request reset.'); } + finally { setBusy(false); } + } + + async function submitReset(event: FormEvent) { + event.preventDefault(); + setBusy(true); setMessage(''); + try { + const response = await fetch('/api/auth/password-reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token, password }) }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.detail ?? 'Unable to reset password.'); + setMessage('Password reset complete. You can sign in now.'); + setToken(''); setPassword(''); + } catch (error) { setMessage(error instanceof Error ? error.message : 'Unable to reset password.'); } + finally { setBusy(false); } + } + + return
Already have a reset link?
{message &&

{message}

}
; +} diff --git a/website/components/site-header.tsx b/website/components/site-header.tsx new file mode 100644 index 0000000..991eb43 --- /dev/null +++ b/website/components/site-header.tsx @@ -0,0 +1,22 @@ +import Link from 'next/link'; + +export function SiteHeader() { + return ( +
+ + M + MotorSignal + + +
+ Sign in + Create account +
+
+ ); +} diff --git a/website/components/verify-email-form.tsx b/website/components/verify-email-form.tsx new file mode 100644 index 0000000..714fa57 --- /dev/null +++ b/website/components/verify-email-form.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { FormEvent, useEffect, useState } from 'react'; + +export function VerifyEmailForm() { + const [token, setToken] = useState(''); + const [message, setMessage] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { setToken(new URLSearchParams(window.location.search).get('token') ?? ''); }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); setBusy(true); setMessage(''); + try { + const response = await fetch('/api/auth/verify-email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token }) }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.detail ?? 'Unable to verify email.'); + setMessage('Email verified. You can return to your account.'); + } catch (error) { setMessage(error instanceof Error ? error.message : 'Unable to verify email.'); } + finally { setBusy(false); } + } + + return
{message &&

{message}

}
; +} diff --git a/website/next-env.d.ts b/website/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/website/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/website/next.config.ts b/website/next.config.ts new file mode 100644 index 0000000..50e097b --- /dev/null +++ b/website/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + poweredByHeader: false, + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 0000000..7b0c985 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,1016 @@ +{ + "name": "motorsignal-website", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "motorsignal-website", + "version": "0.1.0", + "dependencies": { + "next": "16.3.4", + "react": "19.1.1", + "react-dom": "19.1.1" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "typescript": "^5.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", + "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz", + "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz", + "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz", + "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz", + "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz", + "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz", + "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz", + "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz", + "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", + "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.4", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.4", + "@next/swc-darwin-x64": "16.3.4", + "@next/swc-linux-arm64-gnu": "16.3.4", + "@next/swc-linux-arm64-musl": "16.3.4", + "@next/swc-linux-x64-gnu": "16.3.4", + "@next/swc-linux-x64-musl": "16.3.4", + "@next/swc-win32-arm64-msvc": "16.3.4", + "@next/swc-win32-x64-msvc": "16.3.4", + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..0a4b6a7 --- /dev/null +++ b/website/package.json @@ -0,0 +1,22 @@ +{ + "name": "motorsignal-website", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "16.3.4", + "react": "19.1.1", + "react-dom": "19.1.1" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "typescript": "^5.9.0" + } +} diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 0000000..456289b --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}