Improve ride recording and telemetry workflows

This commit is contained in:
Developer 2026-09-08 02:13:03 -04:00
parent 9574b1b4dc
commit 912b4d2a45
71 changed files with 5031 additions and 657 deletions

1
.gitignore vendored
View File

@ -46,6 +46,7 @@ app.*.map.json
*.apk
# Local Agent / IDE cache & scratch artifacts
.codex/
.agent/
.agents/
.agent-backup/

0
1.29 Normal file
View File

View File

@ -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=

10
backend/motorsignal_api/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
__pycache__/
*.py[cod]
*.bad-*
.env
.env.*
!.env.example
venv/
.venv/
.python-packages/
tests/__pycache__/

View File

@ -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.

View File

@ -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 <script_location>/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

View File

@ -0,0 +1 @@
Generic single-database configuration.

View File

@ -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()

View File

@ -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"}

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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],
)

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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.

View File

@ -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.

View File

@ -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,

View File

@ -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,

View File

@ -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';
@ -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,10 +214,13 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
if (!isShortPortrait) const Spacer(flex: 1),
Flexible(
flex: isShortPortrait ? 3 : 4,
child: const Center(
child: Center(
child: Transform.translate(
offset: Offset(0, -12),
child: SpeedReadout(size: SpeedReadoutSize.hero),
),
),
),
if (!isShortPortrait) const Spacer(flex: 1),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
@ -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(
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)),
),
),
);
}
}

View File

@ -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()
: 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(
@ -327,8 +346,11 @@ class _AddressAutocompleteFieldState
)
: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear,
size: 18, color: Colors.white54),
icon: const Icon(
Icons.clear,
size: 18,
color: Colors.white54,
),
onPressed: _clear,
tooltip: 'Clear location',
)
@ -413,22 +435,30 @@ class _AddressAutocompleteFieldState
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
color: colors.warningAmber, size: 18),
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),
color: Colors.white70,
fontSize: 12,
),
),
),
TextButton(
onPressed: () => _performSearch(_controller.text.trim()),
onPressed: () =>
_performSearch(_controller.text.trim()),
child: Text(
'RETRY',
style:
TextStyle(color: colors.cyberCyan, fontSize: 11),
style: TextStyle(
color: colors.cyberCyan,
fontSize: 11,
),
),
),
],
@ -440,7 +470,11 @@ class _AddressAutocompleteFieldState
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_off, color: Colors.white38, size: 16),
Icon(
Icons.location_off,
color: Colors.white38,
size: 16,
),
SizedBox(width: 8),
Text(
'No matching addresses found',
@ -454,8 +488,10 @@ class _AddressAutocompleteFieldState
if (i > 0) const Divider(height: 1, color: Colors.white10),
ListTile(
dense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 2,
),
leading: Icon(
Icons.place_outlined,
color: colors.cyberCyan,
@ -487,7 +523,10 @@ class _AddressAutocompleteFieldState
],
if (widget.showAttribution)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
color: colors.surfaceContainer,
child: const Text(
'Search by Stadia Maps / Pelias • © OpenStreetMap contributors',
@ -507,4 +546,3 @@ class _AddressAutocompleteFieldState
);
}
}

View File

@ -155,45 +155,53 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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),
_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),
const SizedBox(height: 16),
_buildCommunityProviderCard(context),
const SizedBox(height: 16),
_buildTomTomProviderCard(context),
if (kDebugMode) ...[
const SizedBox(height: 16),
_buildDeveloperAlertsCard(context),
if (kDebugMode) _buildDeveloperAlertsCard(context),
],
const SizedBox(height: 24),
_buildSectionHeader('Data & Diagnostics'),
const SizedBox(height: 10),
),
_buildSettingsSection(
context,
title: 'Data & Diagnostics',
icon: Icons.fact_check_outlined,
children: [
_buildDiagnosticsLinkCard(context),
const SizedBox(height: 12),
_buildClearHistoryCard(context),
const SizedBox(height: 16),
_buildFieldTestSection(context),
],
),
const SizedBox(height: 28),
const _AppVersionFooter(),
const SizedBox(height: 16),
@ -201,6 +209,43 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
Widget _buildSettingsSection(
BuildContext context, {
required String title,
required IconData icon,
required List<Widget> 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,

View File

@ -81,8 +81,12 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
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<CreateClubScreen> {
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<CreateClubScreen> {
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<CreateClubScreen> {
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<CreateClubScreen> {
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<CreateClubScreen> {
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,75 +350,103 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
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(
// 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',
hintText: 'e.g. NorCal',
prefixIcon: const Icon(Icons.location_on_outlined, size: 18),
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),
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,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
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',
hintText: 'e.g. Touring, Adventure, Commuting',
prefixIcon: const Icon(Icons.two_wheeler_outlined, size: 18),
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),
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,
),
),
),
),
],
),
const SizedBox(height: 24),
// Visibility Selection
@ -422,13 +482,16 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
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<CreateClubScreen> {
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
valueColor: AlwaysStoppedAnimation<Color>(
Colors.black,
),
),
)
: const Text(
@ -462,137 +527,94 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
);
}
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<ClubVisibility>(
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<ClubAdmissionPolicy>(
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(
Widget _buildFieldLabel(
String title,
String description,
MotorSignalThemeColors colors,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: selected ? colors.cyberCyan : null,
color: colors.cyberCyan,
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
const SizedBox(height: 2),
const SizedBox(height: 3),
Text(
subtitle,
description,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11.5,
color: theme.colorScheme.onSurfaceVariant,
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),
),
);
}
}

View File

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
}
MCPToolkitBinding.instance
..initialize()
..initializeFlutterToolkit();
try {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,

View File

@ -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;
@ -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 = <String, String>{
'query': clean,
};
final queryParams = <String, String>{'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<dynamic>?)
?.whereType<String>()
.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<dynamic>? ?? [];
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,

View File

@ -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"))

View File

@ -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:

View File

@ -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:

View File

@ -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 : [],

View File

@ -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 {

View File

@ -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,7 +210,9 @@ void main() {
expect(find.text(''), findsOneWidget);
});
testWidgets('resolves place via provider if coordinates are missing in search result', (tester) async {
testWidgets(
'resolves place via provider if coordinates are missing in search result',
(tester) async {
final fake = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(
@ -239,9 +234,7 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
activeRouteCalculationProvider.overrideWithValue(fake),
],
overrides: [activeRouteCalculationProvider.overrideWithValue(fake)],
child: MaterialApp(
home: Scaffold(
body: AddressAutocompleteField(
@ -265,6 +258,7 @@ void main() {
expect(resolved, isNotNull);
expect(resolved!.latitude, 34.8698);
expect(resolved!.longitude, -83.9182);
});
},
);
});
}

View File

@ -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', (

View File

@ -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,7 +10,9 @@ import 'package:motorcycle_dash/services/routes/valhalla_route_provider.dart';
void main() {
group('ValhallaRouteProvider Tests', () {
test('throws authenticationRequired when token provider returns null', () async {
test(
'throws authenticationRequired when token provider returns null',
() async {
final provider = ValhallaRouteProvider(
backendUrl: 'http://localhost:3000',
tokenProvider: () async => null,
@ -35,13 +37,16 @@ void main() {
expect(
() => provider.calculateRoute(stops),
throwsA(isA<RouteProviderException>().having(
throwsA(
isA<RouteProviderException>().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<RouteProviderException>().having(
throwsA(
isA<RouteProviderException>().having(
(e) => e.code,
'code',
'invalidRequest',
)),
),
),
);
});
@ -86,15 +93,19 @@ void main() {
expect(
() => provider.calculateRoute(stops),
throwsA(isA<RouteProviderException>().having(
throwsA(
isA<RouteProviderException>().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');
@ -148,9 +159,61 @@ void main() {
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 {
return http.Response(
jsonEncode({
'encodedPolylines': [
'gbr`gAnk{nhFwrx@om`H',
'gbr`gAnk{nhFwrx@om`H',
],
'distanceMeters': 1000,
'durationSeconds': 60,
'warnings': [],
}),
200,
headers: {'content-type': 'application/json'},
);
});
test('searches places and parses coordinates from search results', () async {
final provider = ValhallaRouteProvider(
client: mockClient,
tokenProvider: () async => 'valid-test-token',
);
final result = await provider.calculateRoute([
const RouteStop(
id: '1',
displayName: '1',
latitude: 37,
longitude: -122,
order: 0,
),
const RouteStop(
id: '2',
displayName: '2',
latitude: 37.1,
longitude: -122,
order: 1,
),
const RouteStop(
id: '3',
displayName: '3',
latitude: 37.2,
longitude: -122,
order: 2,
),
]);
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');
@ -162,10 +225,11 @@ void main() {
'providerId': 'place_123',
'primaryText': "Alice's Restaurant",
'secondaryText': 'Woodside, CA',
'formattedAddress': "Alice's Restaurant, Skyline Blvd, Woodside, CA",
'formattedAddress':
"Alice's Restaurant, Skyline Blvd, Woodside, CA",
'latitude': 37.3868,
'longitude': -122.2644,
}
},
],
'attribution': 'Powered by Stadia Maps',
}),
@ -194,7 +258,8 @@ void main() {
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<RouteProviderException>().having(
throwsA(
isA<RouteProviderException>().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({
return http.Response(
jsonEncode({
'encodedPolyline': 'w~j_~A_{n~P',
'distanceMeters': 1234.5,
'durationSeconds': 600,
'warnings': []
}), 200);
'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({
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);
'longitude': 10.0,
}),
200,
);
});
final provider = ValhallaRouteProvider(
@ -344,20 +447,28 @@ void main() {
expect(customProvider.backendUrl, 'http://custom-host:8080');
});
test('activeRouteCalculationProvider resolves to ValhallaRouteProvider by default', () {
test(
'activeRouteCalculationProvider resolves to ValhallaRouteProvider by default',
() {
final container = ProviderContainer();
addTearDown(container.dispose);
final activeProvider = container.read(activeRouteCalculationProvider);
expect(activeProvider, isA<ValhallaRouteProvider>());
expect(activeProvider.name, 'Stadia Maps (Valhalla)');
});
},
);
test('provider errors remain explicit and do not silently fall back to straight line', () async {
test(
'provider errors remain explicit and do not silently fall back to straight line',
() async {
final mockClient = MockClient((request) async {
return http.Response(jsonEncode({
return http.Response(
jsonEncode({
'error': 'providerUnavailable',
'message': 'Routing server unavailable'
}), 503);
'message': 'Routing server unavailable',
}),
503,
);
});
final provider = ValhallaRouteProvider(
@ -366,18 +477,33 @@ 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,
),
];
expect(
() => provider.calculateRoute(stops),
throwsA(isA<RouteProviderException>().having(
throwsA(
isA<RouteProviderException>().having(
(e) => e.code,
'code',
'providerUnavailable',
)),
),
),
);
},
);
});
});
}

1
website/.env.example Normal file
View File

@ -0,0 +1 @@
MOTORSIGNAL_API_BASE_URL=https://api.motorsignal.app

4
website/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.next/
node_modules/
.env*
!.env.example

9
website/AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->

1
website/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

View File

@ -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 <section className="content-page"><span className="eyebrow">Account portal</span><h1>Sign in to your rider space.</h1><p>Manage your MotorSignal account, security, vehicles, and ride history from one place.</p><div className="hero-actions"><Link className="button" href="/login">Sign in</Link><Link className="button button-secondary" href="/register">Create account</Link></div></section>;
return <section className="content-page"><span className="eyebrow">Account portal</span><h1>Welcome back.</h1><p>{account.user.email}</p><div className="account-grid"><article className="panel"><h2>Profile</h2><p>Account status: <strong>Active</strong></p><p>Email verification: <strong>{account.user.email_verified ? 'Verified' : 'Pending'}</strong></p></article><article className="panel"><h2>Plan</h2><p><strong>{account.subscription.plan}</strong> · {account.subscription.status}</p></article><article className="panel"><h2>Vehicles</h2>{account.vehicles.length ? account.vehicles.map((vehicle) => <p key={vehicle.id}><strong>{vehicle.nickname}</strong> · {vehicle.year ?? ''} {vehicle.make} {vehicle.model}</p>) : <p>No vehicles yet.</p>}</article><article className="panel"><h2>Recent rides</h2>{account.rides.length ? account.rides.map((ride) => <p key={ride.id}>{new Date(ride.started_at).toLocaleDateString()} · {ride.distance_miles ?? 0} mi · {ride.status}</p>) : <p>No rides yet.</p>}</article></div></section>;
}

View File

@ -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<string, unknown> | 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 <section className="content-page"><span className="eyebrow">Admin console</span><h1>Sign in required.</h1><p>Admin tools require an authenticated MotorSignal session.</p><Link className="button" href="/login">Sign in</Link></section>;
if (result.status === 403) return <section className="content-page"><span className="eyebrow">Admin console</span><h1>Access restricted.</h1><p>Your account does not have support or admin permissions.</p><Link className="button button-secondary" href="/account">Back to account</Link></section>;
if (result.status !== 200) return <section className="content-page"><span className="eyebrow">Admin console</span><h1>Console unavailable.</h1><p>Audit data could not be loaded. Try again when the API is available.</p></section>;
return <section className="content-page"><span className="eyebrow">Admin console</span><h1>Operations.</h1><p>Manage users, vehicles, rides, and review recent account activity.</p><AdminConsole /><h2 className="admin-audit-heading">Recent audit events</h2><div className="audit-list">{result.events.length === 0 ? <div className="panel"><p>No events recorded.</p></div> : result.events.map((event) => <article className="panel audit-row" key={event.id}><div><strong>{event.event_type}</strong><p>{event.actor_user_id ?? 'System'} {event.target_user_id ? `targeted ${event.target_user_id}` : ''}</p></div><time dateTime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time></article>)}</div></section>;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -0,0 +1,13 @@
import Link from 'next/link';
export default function DownloadPage() {
return <section className="content-page">
<span className="eyebrow">Ride with more signal</span>
<h1>MotorSignal for your motorcycle.</h1>
<p>Install the mobile app to bring live telemetry, ride recording, route context, and performance history to your cockpit.</p>
<div className="section-grid section-grid-two">
<article className="panel"><h2>Android</h2><p>The Android build is available for device testing and release distribution.</p><Link className="button" href="/support">Request the latest build</Link></article>
<article className="panel"><h2>Already installed?</h2><p>Sign in to manage your account and keep your ride data connected.</p><Link className="button button-secondary" href="/login">Open account</Link></article>
</div>
</section>;
}

View File

@ -0,0 +1 @@
export default function FeaturesPage() { return <section className="hero"><span className="eyebrow">The platform</span><h1>Every signal has a job.</h1><p>MotorSignal brings telemetry, route context, performance analysis, group rides, and secure cloud sync into one rider-focused system.</p><div className="section-grid"><article className="panel"><h3>Telemetry</h3><p>Live speed, GPS, lean angle, acceleration, braking, and ride state.</p></article><article className="panel"><h3>Performance</h3><p>Launch timing and ride metrics stay associated with the ride that produced them.</p></article><article className="panel"><h3>Community</h3><p>Plan routes, discover clubs, and ride with people who share your road.</p></article></div></section>; }

View File

@ -0,0 +1,3 @@
import { RecoveryForm } from '../../components/recovery-form';
export default function ForgotPasswordPage() { return <section className="auth-page"><div><span className="eyebrow">Account security</span><h1>Reset access safely.</h1><p>Request a short-lived reset link, then choose a new password.</p></div><div className="auth-card"><RecoveryForm /></div></section>; }

68
website/app/globals.css Normal file
View File

@ -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; } }

23
website/app/layout.tsx Normal file
View File

@ -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 (
<html lang="en">
<body>
<SiteHeader />
<main>{children}</main>
<footer className="site-footer">
<span>MotorSignal</span>
<span>Ride smarter. Know more. <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></span>
</footer>
</body>
</html>
);
}

View File

@ -0,0 +1,4 @@
import Link from 'next/link';
import { AuthForm } from '../../components/auth-form';
export default function LoginPage() { return <section className="auth-page"><div><span className="eyebrow">Rider account</span><h1>Welcome back.</h1><p>Sign in to access your MotorSignal account.</p></div><div className="auth-card"><AuthForm mode="login" /><Link className="muted-link" href="/forgot-password">Forgot your password?</Link></div></section>; }

17
website/app/page.tsx Normal file
View File

@ -0,0 +1,17 @@
import Link from 'next/link';
export default function HomePage() {
return <>
<section className="hero">
<span className="eyebrow">Motorcycle telemetry, made useful</span>
<h1>Know what your ride is doing.</h1>
<p>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.</p>
<div className="hero-actions"><Link className="button" href="/register">Create your account</Link><Link className="button button-secondary" href="/features">Explore features</Link></div>
</section>
<section className="section-grid" aria-label="MotorSignal capabilities">
<article className="panel"><h3>Live cockpit</h3><p>Speed, lean angle, G-force, GPS status, and ride state in one rider-first dashboard.</p></article>
<article className="panel"><h3>Ride intelligence</h3><p>Review distance, speed, performance launches, route context, and telemetry summaries after every ride.</p></article>
<article className="panel"><h3>Built for trust</h3><p>Your account, sessions, vehicles, and ride data stay behind authenticated HTTPS APIs.</p></article>
</section>
</>;
}

View File

@ -0,0 +1 @@
export default function PricingPage() { return <section className="hero"><span className="eyebrow">Plans</span><h1>Start with the ride.</h1><p>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.</p><div className="panel" style={{ marginTop: 28, maxWidth: 430 }}><h3>MotorSignal account</h3><p>Secure sign-in, device sessions, and your rider data portal.</p></div></section>; }

View File

@ -0,0 +1,3 @@
export default function PrivacyPage() {
return <section className="content-page legal-page"><span className="eyebrow">Your data</span><h1>Privacy</h1><p>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.</p><h2>What we collect</h2><p>Information you provide, device and session details, and ride data generated when you use telemetry and recording features.</p><h2>How we use it</h2><p>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.</p><h2>Your choices</h2><p>You can manage your account through the portal and contact support for access, correction, or deletion requests.</p></section>;
}

View File

@ -0,0 +1,4 @@
import Link from 'next/link';
import { AuthForm } from '../../components/auth-form';
export default function RegisterPage() { return <section className="auth-page"><div><span className="eyebrow">Rider account</span><h1>Bring your data with you.</h1><p>Create a secure MotorSignal account for your rides, devices, and future web portal.</p></div><div className="auth-card"><AuthForm mode="register" /><p>Already have an account? <Link className="muted-link" href="/login">Sign in</Link></p></div></section>; }

View File

@ -0,0 +1 @@
export default function SupportPage() { return <section className="hero"><span className="eyebrow">Support</span><h1>Help for the road ahead.</h1><p>Account and product support will be available here as the MotorSignal web platform comes online.</p><a className="button" href="mailto:support@motorsignal.app">Contact support</a></section>; }

View File

@ -0,0 +1,3 @@
export default function TermsPage() {
return <section className="content-page legal-page"><span className="eyebrow">Service terms</span><h1>Terms of service</h1><p>MotorSignal provides motorcycle telemetry and ride-management tools. Use the service lawfully and keep account credentials secure.</p><h2>Ride safely</h2><p>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.</p><h2>Account responsibility</h2><p>You are responsible for activity under your account and for the accuracy of information you submit. We may restrict abusive or unsafe use.</p><h2>Service changes</h2><p>Features may evolve as we improve reliability and security. Material changes will be communicated through the service when appropriate.</p></section>;
}

View File

@ -0,0 +1,6 @@
import Link from 'next/link';
import { VerifyEmailForm } from '../../components/verify-email-form';
export default function VerifyEmailPage() {
return <section className="auth-page"><div><span className="eyebrow">Account security</span><h1>Verify your email.</h1><p>Use the link in your MotorSignal email to confirm ownership of your address.</p><Link className="button button-secondary" href="/login">Return to sign in</Link></div><div className="auth-card"><h2>Confirm address</h2><VerifyEmailForm /><Link className="muted-link" href="/support">Contact support</Link></div></section>;
}

View File

@ -0,0 +1,47 @@
'use client';
import { FormEvent, useEffect, useState } from 'react';
type Resource = 'users' | 'vehicles' | 'rides';
type Item = Record<string, string | number | boolean | null>;
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<Resource>('users');
const [items, setItems] = useState<Item[]>([]);
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<HTMLFormElement>) {
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 <div className="admin-console"><div className="admin-tabs">{(['users', 'vehicles', 'rides'] as Resource[]).map((value) => <button className={resource === value ? 'admin-tab active' : 'admin-tab'} key={value} onClick={() => setResource(value)} type="button">{value}</button>)}</div><form className="admin-create-form" onSubmit={create}>{resource === 'users' && <><input name="email" required placeholder="Email" type="email" /><input name="password" required minLength={12} placeholder="Temporary password" type="password" /><select defaultValue="user" name="role"><option value="user">User</option><option value="support">Support</option><option value="admin">Admin</option></select></>}{resource === 'vehicles' && <><input name="user_id" required placeholder="Owner user ID" /><input name="nickname" required placeholder="Nickname" /><input name="make" required placeholder="Make" /><input name="model" required placeholder="Model" /><input name="year" placeholder="Year" type="number" /></>}{resource === 'rides' && <><input name="user_id" required placeholder="Rider user ID" /><input name="vehicle_id" placeholder="Vehicle ID" /><input name="started_at" required type="datetime-local" /><input name="distance_miles" placeholder="Distance miles" type="number" step="0.1" /><input name="top_speed_mph" placeholder="Top speed MPH" type="number" step="0.1" /></>}<button className="button" disabled={busy} type="submit">Create {resource.slice(0, -1)}</button></form>{message && <p className="form-message" role="status">{message}</p>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr>{resource === 'users' ? <><th>Email</th><th>Role</th><th>Status</th><th /></> : resource === 'vehicles' ? <><th>Nickname</th><th>Make / model</th><th>Owner</th><th /></> : <><th>Ride</th><th>Rider</th><th>Distance</th><th>Status</th><th /></>}</tr></thead><tbody>{items.map((item) => <tr key={String(item.id)}>{resource === 'users' ? <><td>{String(item.email)}</td><td>{String(item.role)}</td><td>{item.is_active ? 'Active' : 'Inactive'}</td><td><button className="text-button" onClick={() => void archive(String(item.id))} type="button">Deactivate</button></td></> : resource === 'vehicles' ? <><td>{String(item.nickname)}</td><td>{String(item.make)} {String(item.model)}</td><td>{String(item.user_id)}</td><td><button className="text-button" onClick={() => void archive(String(item.id))} type="button">Delete</button></td></> : <><td>{String(item.id).slice(0, 8)}</td><td>{String(item.user_id)}</td><td>{item.distance_miles == null ? '-' : `${item.distance_miles} mi`}</td><td>{String(item.status)}</td><td>{item.status !== 'archived' && <button className="text-button" onClick={() => void archive(String(item.id))} type="button">Archive</button>}</td></>}</tr>)}</tbody></table>{!busy && items.length === 0 && <p className="empty-state">No {resource} found.</p>}</div></div>;
}

View File

@ -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<HTMLFormElement>) {
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 (
<form className="auth-form" onSubmit={submit}>
<label>Email<input required type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} /></label>
<label>Password<input required minLength={12} type="password" autoComplete={mode === 'login' ? 'current-password' : 'new-password'} value={password} onChange={(e) => setPassword(e.target.value)} /></label>
<label>Device name<input maxLength={255} value={deviceName} onChange={(e) => setDeviceName(e.target.value)} /></label>
{message && <p className="form-error" role="alert">{message}</p>}
<button className="button" disabled={busy} type="submit">{busy ? 'Working...' : mode === 'login' ? 'Sign in' : 'Create account'}</button>
</form>
);
}

View File

@ -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 <div className="recovery-stack"><form className="auth-form" onSubmit={submitRequest}><label>Email<input required type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label><button className="button" disabled={busy} type="submit">{busy ? 'Working...' : 'Email reset link'}</button></form><div className="form-divider">Already have a reset link?</div><form className="auth-form" onSubmit={submitReset}><label>Reset token<input required value={token} onChange={(event) => setToken(event.target.value)} /></label><label>New password<input required minLength={12} type="password" autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label><button className="button button-secondary" disabled={busy} type="submit">Set new password</button></form>{message && <p className="form-message" role="status">{message}</p>}</div>;
}

View File

@ -0,0 +1,22 @@
import Link from 'next/link';
export function SiteHeader() {
return (
<header className="site-header">
<Link className="brand" href="/" aria-label="MotorSignal home">
<span className="brand-mark">M</span>
<span>MotorSignal</span>
</Link>
<nav aria-label="Primary navigation">
<Link href="/features">Features</Link>
<Link href="/pricing">Pricing</Link>
<Link href="/download">Download</Link>
<Link href="/support">Support</Link>
</nav>
<div className="header-actions">
<Link className="quiet-link" href="/login">Sign in</Link>
<Link className="button button-small" href="/register">Create account</Link>
</div>
</header>
);
}

View File

@ -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 <form className="auth-form" onSubmit={submit}><label>Verification token<input required value={token} onChange={(event) => setToken(event.target.value)} /></label><button className="button" disabled={busy} type="submit">{busy ? 'Checking...' : 'Verify email'}</button>{message && <p className="form-message" role="status">{message}</p>}</form>;
}

7
website/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

8
website/next.config.ts Normal file
View File

@ -0,0 +1,8 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
poweredByHeader: false,
reactStrictMode: true,
};
export default nextConfig;

1016
website/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
website/package.json Normal file
View File

@ -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"
}
}

36
website/tsconfig.json Normal file
View File

@ -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"
]
}