Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc0cf80000 | |||
| a5cdabf6de | |||
| a9137f6505 | |||
| 98bbe67406 | |||
| ffbc4849a7 |
@@ -4,10 +4,13 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
|||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
# JWT
|
# JWT / Auth
|
||||||
SECRET_KEY=change-me-to-a-secure-random-string
|
ALGORITHM=RS256
|
||||||
ALGORITHM=HS256
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||||
|
# Directory where the RSA keypair is loaded/generated at runtime.
|
||||||
|
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
|
||||||
|
RSA_KEY_DIR=~/.agp/keys
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_NAME=AGP Backend
|
APP_NAME=AGP Backend
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add refresh_tokens table
|
||||||
|
|
||||||
|
Revision ID: 0003_add_refresh_tokens_table
|
||||||
|
Revises: 0002_add_date_of_birth_to_users
|
||||||
|
Create Date: 2025-07-25 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0003_add_refresh_tokens_table"
|
||||||
|
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create the refresh_tokens table."""
|
||||||
|
op.create_table(
|
||||||
|
"refresh_tokens",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop the refresh_tokens table."""
|
||||||
|
op.drop_table("refresh_tokens")
|
||||||
+79
-5
@@ -1,10 +1,56 @@
|
|||||||
"""Application configuration via pydantic-settings."""
|
"""Application configuration via pydantic-settings."""
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.rsa import (
|
||||||
|
RSAPrivateKey,
|
||||||
|
RSAPublicKey,
|
||||||
|
generate_private_key,
|
||||||
|
)
|
||||||
|
from cryptography.hazmat.primitives.serialization import (
|
||||||
|
Encoding,
|
||||||
|
NoEncryption,
|
||||||
|
PrivateFormat,
|
||||||
|
PublicFormat,
|
||||||
|
load_pem_private_key,
|
||||||
|
load_pem_public_key,
|
||||||
|
)
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]:
|
||||||
|
"""Load existing RSA keys or generate a new pair.
|
||||||
|
|
||||||
|
Keys are stored as PEM files in *key_dir*. The directory is created
|
||||||
|
if it does not exist. Files are named ``private.pem`` and
|
||||||
|
``public.pem`` and are **not** committed to git.
|
||||||
|
"""
|
||||||
|
private_path = key_dir / "private.pem"
|
||||||
|
public_path = key_dir / "public.pem"
|
||||||
|
|
||||||
|
if private_path.exists() and public_path.exists():
|
||||||
|
private_pem = private_path.read_bytes()
|
||||||
|
public_pem = public_path.read_bytes()
|
||||||
|
else:
|
||||||
|
# Generate a new 2048-bit RSA key pair
|
||||||
|
private_key = generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
private_pem = private_key.private_bytes(
|
||||||
|
Encoding.PEM,
|
||||||
|
PrivateFormat.PKCS8,
|
||||||
|
NoEncryption(),
|
||||||
|
)
|
||||||
|
public_pem = private_key.public_key().public_bytes(
|
||||||
|
Encoding.PEM,
|
||||||
|
PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
key_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
private_path.write_bytes(private_pem)
|
||||||
|
public_path.write_bytes(public_pem)
|
||||||
|
|
||||||
|
return private_pem, public_pem
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
# App
|
# App
|
||||||
APP_NAME: str = "AGP Backend"
|
APP_NAME: str = "AGP Backend"
|
||||||
@@ -18,11 +64,13 @@ class Settings(BaseSettings):
|
|||||||
REDIS_URL: str = "redis://localhost:6379/0"
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
# JWT / Auth
|
# JWT / Auth
|
||||||
# NOTE: production must override SECRET_KEY with a secure random value
|
ALGORITHM: str = "RS256"
|
||||||
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||||
SECRET_KEY: str = "change-me-to-a-secure-random-string"
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||||
ALGORITHM: str = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
# RS256 key storage. Defaults to a directory outside the repo so the
|
||||||
|
# generated keypair is never accidentally committed.
|
||||||
|
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
||||||
@@ -42,6 +90,32 @@ class Settings(BaseSettings):
|
|||||||
def cors_origins_list(self) -> list[str]:
|
def cors_origins_list(self) -> list[str]:
|
||||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rsa_key_dir(self) -> Path:
|
||||||
|
return Path(self.RSA_KEY_DIR)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def private_pem(self) -> bytes:
|
||||||
|
"""Return the private key PEM bytes (loads/generates on first call)."""
|
||||||
|
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
|
||||||
|
return private_pem
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_pem(self) -> bytes:
|
||||||
|
"""Return the public key PEM bytes (loads/generates on first call)."""
|
||||||
|
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
|
||||||
|
return public_pem
|
||||||
|
|
||||||
|
@property
|
||||||
|
def private_key(self) -> RSAPrivateKey:
|
||||||
|
"""Return the deserialized RSA private key object."""
|
||||||
|
return load_pem_private_key(self.private_pem, password=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_key(self) -> RSAPublicKey:
|
||||||
|
"""Return the deserialized RSA public key object."""
|
||||||
|
return load_pem_public_key(self.public_pem)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async def get_current_user(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
token, settings.public_pem, algorithms=[settings.ALGORITHM]
|
||||||
)
|
)
|
||||||
user_id: str | None = payload.get("sub")
|
user_id: str | None = payload.get("sub")
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import uuid
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -18,3 +19,19 @@ class Session(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
user = relationship("User", back_populates="sessions")
|
user = relationship("User", back_populates="sessions")
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(Base):
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
# RS256 JWTs are ~420+ chars; Text avoids length issues and is fine for
|
||||||
|
# a UUID-indexed lookup by hash in the future.
|
||||||
|
token = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="refresh_tokens")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class User(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||||
worlds = relationship("World", back_populates="owner")
|
worlds = relationship("World", back_populates="owner")
|
||||||
assets = relationship("Asset", back_populates="owner")
|
assets = relationship("Asset", back_populates="owner")
|
||||||
|
|
||||||
|
|||||||
+67
-11
@@ -1,20 +1,21 @@
|
|||||||
"""Authentication router - login, register, token refresh."""
|
"""Authentication router - login, register, token refresh."""
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from pydantic import BaseModel, EmailStr, field_validator
|
|
||||||
|
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import CurrentUser
|
from app.dependencies import CurrentUser
|
||||||
|
from app.models.session import RefreshToken
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
@@ -25,7 +26,9 @@ router = APIRouter()
|
|||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
access_token_expires_minutes: int
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
@@ -59,12 +62,31 @@ class UserResponse(BaseModel):
|
|||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
def _create_access_token(data: dict) -> str:
|
def _create_access_token(data: dict) -> str:
|
||||||
|
"""Create a signed access token using RS256."""
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
)
|
)
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
return jwt.encode(
|
||||||
|
to_encode,
|
||||||
|
settings.private_pem,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_refresh_token(data: dict) -> str:
|
||||||
|
"""Create a signed refresh token using RS256."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||||
|
)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(
|
||||||
|
to_encode,
|
||||||
|
settings.private_pem,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
@@ -77,7 +99,7 @@ def hash_password(password: str) -> str:
|
|||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
def calculate_age(birth_date, today=None) -> int:
|
||||||
"""Calculate age in years from a birth date.
|
"""Calculate age in years from a birth date.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -97,11 +119,25 @@ def calculate_age(birth_date: date, today: date | None = None) -> int:
|
|||||||
return age
|
return age
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
||||||
|
"""Create and persist a refresh token for the given user."""
|
||||||
|
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
|
||||||
|
refresh_token_record = RefreshToken(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=user_id,
|
||||||
|
token=refresh_token_value,
|
||||||
|
expires_at=datetime.now(timezone.utc)
|
||||||
|
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||||
|
)
|
||||||
|
db.add(refresh_token_record)
|
||||||
|
return refresh_token_value
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
"""Register a new user and return an access token.
|
"""Register a new user and return access + refresh tokens.
|
||||||
|
|
||||||
Requires date_of_birth and rejects users under 18.
|
Requires date_of_birth and rejects users under 18.
|
||||||
"""
|
"""
|
||||||
@@ -141,13 +177,23 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
access_token = _create_access_token({"sub": str(user.id)})
|
||||||
return TokenResponse(access_token=access_token)
|
|
||||||
|
# Create and persist refresh token
|
||||||
|
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token_value,
|
||||||
|
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||||
"""Authenticate user and return an access token.
|
"""Authenticate user and return access + refresh tokens.
|
||||||
|
|
||||||
Accepts form-encoded credentials (username/password) as expected by
|
Accepts form-encoded credentials (username/password) as expected by
|
||||||
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
||||||
@@ -167,8 +213,18 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
|
|||||||
detail="User account is inactive",
|
detail="User account is inactive",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
access_token = _create_access_token({"sub": str(user.id)})
|
||||||
return TokenResponse(access_token=access_token)
|
|
||||||
|
# Create and persist refresh token
|
||||||
|
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token_value,
|
||||||
|
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ explicitly out of scope.
|
|||||||
| # | Type | Task | Depends on |
|
| # | Type | Task | Depends on |
|
||||||
|---|------|------|-----------|
|
|---|------|------|-----------|
|
||||||
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
||||||
| 2 | implementation | Main-menu **login + register** UI built & wired (form-encoded + JSON/age gate) | 1 |
|
| 2 | implementation | Main-menu **login** wiring (form-encoded) | 1 |
|
||||||
| 3 | implementation | **World list** screen consuming `GET /api/worlds` | 1 |
|
| 3 | implementation | Main-menu **register** wiring (JSON + age gate) | 2 |
|
||||||
| 4 | testing | End-to-end headless smoke test + manual verify | 1,2,3 |
|
| 4 | implementation | **World list** screen consuming `GET /api/worlds` | 2 |
|
||||||
|
| 5 | testing | End-to-end headless smoke test + manual verify | 1,3,4 |
|
||||||
|
|
||||||
## Design decisions captured (resolved in task 1)
|
## Design decisions captured (resolved in task 1)
|
||||||
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
||||||
@@ -34,8 +35,3 @@ explicitly out of scope.
|
|||||||
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
|
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
|
||||||
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
|
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
|
||||||
- Login is form-encoded; register is JSON — the client helper must support both content types.
|
- Login is form-encoded; register is JSON — the client helper must support both content types.
|
||||||
|
|
||||||
## Notes
|
|
||||||
- The main-menu auth UI does **not** exist yet (main_menu.gd currently has only Play/Quit
|
|
||||||
stubs). Task 2 must **build** the login/register UI, not merely wire an existing one.
|
|
||||||
- The world-list screen also needs to be **built** (a new scene), not just wired.
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cjd0uhll7iylj
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bgnc3iamfgf6k
|
||||||
Reference in New Issue
Block a user