Improve ride recording and telemetry workflows
This commit is contained in:
parent
9574b1b4dc
commit
912b4d2a45
|
|
@ -46,6 +46,7 @@ app.*.map.json
|
|||
*.apk
|
||||
|
||||
# Local Agent / IDE cache & scratch artifacts
|
||||
.codex/
|
||||
.agent/
|
||||
.agents/
|
||||
.agent-backup/
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*.bad-*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
venv/
|
||||
.venv/
|
||||
.python-packages/
|
||||
tests/__pycache__/
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration.
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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],
|
||||
)
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
72
pubspec.lock
72
pubspec.lock
|
|
@ -297,6 +297,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
dart_mcp:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_mcp
|
||||
sha256: "852b51da915d679be8d051e2e3b6c68ed19fe685f1c676bb35272a2d73ceac7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.2"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -483,6 +491,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
flutter_mcp_toolkit_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_mcp_toolkit_core
|
||||
sha256: fc61b977983b0615b84c27404db86c18bb703af519867b51fb72136f95682148
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.0"
|
||||
flutter_native_splash:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
@ -573,6 +589,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
from_json_to_json:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: from_json_to_json
|
||||
sha256: a29219df65cd20b1b17be8141ee51b3103d1cd95b192b7512139f3ae44775f49
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
geoclue:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -853,6 +877,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.11+2"
|
||||
intentcall_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intentcall_core
|
||||
sha256: "10363edd11dab107d83d5126decec03d07ab6bf9fa4685a62f9f9d9c0af13cea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
intentcall_platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intentcall_platform
|
||||
sha256: a14d533b5ab2413c67ce2f943b56ab2a335a1a46b88a00ae62d72fafc6cacf50
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
intentcall_schema:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intentcall_schema
|
||||
sha256: "7fd95e20f8d32f5ffe6c9cd4058d3fbf513c52dff69a63d9c4c8f4bf2810f842"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -869,6 +917,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
is_dart_empty_or_not:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: is_dart_empty_or_not
|
||||
sha256: "1454632c2b961175d4c2807310713cbcbd054a51e63c545dc86c3eb9b2b061b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -901,6 +957,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
json_rpc_2:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_rpc_2
|
||||
sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
latlong2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -997,6 +1061,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
mcp_toolkit:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mcp_toolkit
|
||||
sha256: "75713760da19ef3b0b370e572d7714d51346f2411221ecce86fc5555918c0f2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 : [],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
MOTORSIGNAL_API_BASE_URL=https://api.motorsignal.app
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.next/
|
||||
node_modules/
|
||||
.env*
|
||||
!.env.example
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -0,0 +1 @@
|
|||
@AGENTS.md
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>; }
|
||||
|
|
@ -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>; }
|
||||
|
|
@ -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; } }
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>; }
|
||||
|
|
@ -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>
|
||||
</>;
|
||||
}
|
||||
|
|
@ -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>; }
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>; }
|
||||
|
|
@ -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>; }
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue