diff --git a/.do/app.yaml b/.do/app.yaml new file mode 100644 index 0000000..2fe1e91 --- /dev/null +++ b/.do/app.yaml @@ -0,0 +1,100 @@ +name: generate-admin +region: nyc + +databases: + - name: db + engine: PG + version: "17" + production: true + cluster_name: generate-admin-db + +services: + - name: api + github: + repo: GenerateNU/admin + branch: main + deploy_on_push: true + source_dir: backend + dockerfile_path: backend/Dockerfile + instance_size_slug: apps-s-1vcpu-0.5gb + instance_count: 1 + http_port: 8080 + health_check: + http_path: /health + initial_delay_seconds: 15 + period_seconds: 10 + envs: + - key: DATABASE_URL + scope: RUN_AND_BUILD_TIME + value: postgresql+asyncpg://${db.USERNAME}:${db.PASSWORD}@${db.HOSTNAME}:${db.PORT}/${db.DATABASE}?sslmode=require + - key: APP_ENVIRONMENT + scope: RUN_AND_BUILD_TIME + value: production + - key: APP_LOG_LEVEL + scope: RUN_AND_BUILD_TIME + value: INFO + - key: ENTRA_TENANT_ID + scope: RUN_AND_BUILD_TIME + value: a8eec281-aaa3-4dae-ac9b-9a398b9215e7 + - key: ENTRA_API_CLIENT_ID + scope: RUN_AND_BUILD_TIME + value: 6b24d4eb-4d4c-44c5-8252-cf13aa888eca + - key: CORS_ALLOWED_ORIGINS + scope: RUN_AND_BUILD_TIME + value: ${APP_URL} + - key: INITIAL_OWNER_EMAIL + scope: RUN_AND_BUILD_TIME + value: nguyen.mai4@northeastern.edu + - key: REDIS_URL + scope: RUN_AND_BUILD_TIME + type: SECRET + value: CHANGE_ME + - key: AWS_ACCESS_KEY_ID + scope: RUN_AND_BUILD_TIME + type: SECRET + value: CHANGE_ME + - key: AWS_SECRET_ACCESS_KEY + scope: RUN_AND_BUILD_TIME + type: SECRET + value: CHANGE_ME + - key: AWS_REGION + scope: RUN_AND_BUILD_TIME + value: us-east-1 + - key: S3_ENDPOINT + scope: RUN_AND_BUILD_TIME + value: "" + - key: S3_BUCKET_NAME + scope: RUN_AND_BUILD_TIME + value: generate-admin + - key: S3_PUBLIC_BASE_URL + scope: RUN_AND_BUILD_TIME + value: https://generate-admin.s3.us-east-1.amazonaws.com + +jobs: + - name: migrate + kind: PRE_DEPLOY + github: + repo: GenerateNU/admin + branch: main + deploy_on_push: true + source_dir: backend + dockerfile_path: backend/Dockerfile + instance_size_slug: apps-s-1vcpu-0.5gb + run_command: alembic upgrade head + envs: + - key: DATABASE_URL + scope: RUN_AND_BUILD_TIME + value: postgresql+asyncpg://${db.USERNAME}:${db.PASSWORD}@${db.HOSTNAME}:${db.PORT}/${db.DATABASE}?sslmode=require + - key: APP_ENVIRONMENT + scope: RUN_AND_BUILD_TIME + value: production + - key: ENTRA_TENANT_ID + scope: RUN_AND_BUILD_TIME + value: a8eec281-aaa3-4dae-ac9b-9a398b9215e7 + - key: ENTRA_API_CLIENT_ID + scope: RUN_AND_BUILD_TIME + value: 6b24d4eb-4d4c-44c5-8252-cf13aa888eca + - key: REDIS_URL + scope: RUN_AND_BUILD_TIME + type: SECRET + value: CHANGE_ME diff --git a/.env.template b/.env.template index 7b02103..8ff7fdc 100644 --- a/.env.template +++ b/.env.template @@ -29,3 +29,7 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000 INITIAL_OWNER_EMAIL= INVITATION_TTL_HOURS=336 +FRONTEND_BASE_URL=http://localhost:3000 + +RESEND_API_KEY= +RESEND_FROM_EMAIL= diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index fbd7a7b..28f464a 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -39,45 +39,12 @@ jobs: - name: Mypy run: uv run mypy src - test: - name: test + contract: + name: api contract runs-on: ubuntu-latest - defaults: - run: - working-directory: backend - - services: - postgres: - image: postgres:17-alpine - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: generate_admin - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres -d generate_admin" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - - redis: - image: redis:7-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 5s - --health-timeout 3s - --health-retries 10 - env: - # Ports are the container defaults here, not the offset ones docker-compose - # publishes locally. Everything else falls back to its config default. - DATABASE_URL: postgresql+asyncpg://postgres:postgres@127.0.0.1:5432/generate_admin + DATABASE_URL: postgresql+asyncpg://unused:unused@127.0.0.1:5432/unused REDIS_URL: redis://127.0.0.1:6379/0 - APP_ENVIRONMENT: local - steps: - uses: actions/checkout@v4 @@ -86,11 +53,39 @@ jobs: enable-cache: true cache-dependency-glob: backend/uv.lock + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - name: Install dependencies - run: uv sync --all-groups + run: | + uv sync --all-groups --directory backend + npm ci + + - name: Regenerate schema and client + run: | + uv run --directory backend python -m admin.cli openapi + npm run gen + + - name: Fail if the committed output is stale + run: | + if ! git diff --exit-code -- openapi.json packages/api/src/generated; then + echo "::error::openapi.json or the generated client is out of date. Run 'just gen' and commit." + exit 1 + fi + + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - - name: Run migrations - run: uv run alembic upgrade head + - name: Run tests + run: | + docker compose -f docker-compose.test.yml up --build \ + --abort-on-container-exit --exit-code-from backend-test - - name: Pytest - run: uv run pytest + - name: Tear down + if: always() + run: docker compose -f docker-compose.test.yml down -v diff --git a/.github/workflows/publish-api.yml b/.github/workflows/publish-api.yml new file mode 100644 index 0000000..7917448 --- /dev/null +++ b/.github/workflows/publish-api.yml @@ -0,0 +1,48 @@ +name: Publish API client + +on: + push: + tags: + - "api-v*.*.*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 0.2.0)" + required: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + registry-url: "https://registry.npmjs.org" + + - name: Resolve version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "value=${GITHUB_REF_NAME#api-v}" >> "$GITHUB_OUTPUT" + fi + + - name: Install dependencies + run: npm ci + + - name: Set package version + run: npm version "${{ steps.version.outputs.value }}" --no-git-tag-version --workspace @generatenu/api + + - name: Build + run: npm run build --workspace @generatenu/api + + - name: Publish + run: npm publish --workspace @generatenu/api + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 5dafa4d..2419be7 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ - [uv](https://docs.astral.sh/uv/) (Python 3.12+) - Docker, for Postgres / Redis / LocalStack - [just](https://github.com/casey/just) +- Node 22+, for the frontend (orval requires >= 22.18); `nvm use` picks it up from `.nvmrc` ## Quickstart @@ -20,6 +21,8 @@ just dev # http://localhost:8000 Check it with `curl localhost:8000/health`. API docs are at `/docs`. +For the Next.js admin console, see [`frontend/README.md`](frontend/README.md). + ## Services Docker compose uses offset host ports so it does not collide with anything already running. @@ -41,6 +44,8 @@ Docker compose uses offset host ports so it does not collide with anything alrea | `just rollback` | undo the last migration | | `just revision ` | create a migration | | `just seed` | sync roles and permissions | +| `just openapi` | write `openapi.json` (no server) | +| `just gen` | `openapi` + regenerate the TS client | | `just test` | pytest | | `just lint` | ruff check + format check | | `just fmt` | ruff autofix + format | @@ -61,3 +66,4 @@ uses a real Redis. - test: Postgres and Redis service containers, migrations, then pytest It mirrors `just check`. + diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..41311db --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.env +.env.local diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..49411d5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1 +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never + +WORKDIR /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --no-dev + +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev + + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS test + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never + +WORKDIR /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --all-groups + +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --all-groups + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +CMD ["sh", "-c", "alembic upgrade head && pytest"] + + +FROM python:3.12-slim-bookworm AS runtime + +RUN groupadd --system app && useradd --system --gid app --home-dir /app app + +WORKDIR /app +COPY --from=builder --chown=app:app /app /app +RUN rm -rf /app/tests + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +USER app +EXPOSE 8080 + +CMD ["sh", "-c", "uvicorn admin.main:app --host 0.0.0.0 --port ${PORT:-8080}"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f714275..78251ac 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -53,7 +53,6 @@ plugins = ["pydantic.mypy"] strict = true warn_return_any = false -# These ship without type stubs; everything else stays strict. [[tool.mypy.overrides]] module = ["asyncpg.*", "boto3.*", "botocore.*"] ignore_missing_imports = true diff --git a/backend/src/admin/api/dependencies.py b/backend/src/admin/api/dependencies.py index 206be07..40f7903 100644 --- a/backend/src/admin/api/dependencies.py +++ b/backend/src/admin/api/dependencies.py @@ -9,6 +9,7 @@ from admin.core.audit import AuditLog from admin.core.cache import Cache as CacheProtocol from admin.core.config import Settings, get_settings +from admin.core.email import EmailSender as EmailSenderProtocol from admin.core.errors import ( AccountNotProvisionedError, AccountSuspendedError, @@ -64,6 +65,10 @@ def get_storage(request: Request) -> S3Storage: return request.app.state.storage +def get_email_sender(request: Request) -> EmailSenderProtocol: + return request.app.state.email_sender + + def get_token_verifier(request: Request) -> TokenVerifier: return request.app.state.token_verifier @@ -71,6 +76,7 @@ def get_token_verifier(request: Request) -> TokenVerifier: Connection = Annotated[asyncpg.Connection, Depends(get_connection)] Cache = Annotated[CacheProtocol, Depends(get_read_cache)] Storage = Annotated[S3Storage, Depends(get_storage)] +EmailSender = Annotated[EmailSenderProtocol, Depends(get_email_sender)] AppSettings = Annotated[Settings, Depends(get_settings)] @@ -135,14 +141,21 @@ def get_member_service(users: Users, roles: Roles, audit: Audit) -> MemberServic def get_invitation_service( - invitations: Invitations, roles: Roles, users: Users, audit: Audit, settings: AppSettings + invitations: Invitations, + roles: Roles, + users: Users, + audit: Audit, + email_sender: EmailSender, + settings: AppSettings, ) -> InvitationService: return InvitationService( invitations=invitations, roles=roles, users=users, audit=audit, + email_sender=email_sender, default_ttl_hours=settings.invitation_ttl_hours, + frontend_base_url=settings.frontend_base_url, ) diff --git a/backend/src/admin/api/v1/session.py b/backend/src/admin/api/v1/session.py index e0121a0..75995ca 100644 --- a/backend/src/admin/api/v1/session.py +++ b/backend/src/admin/api/v1/session.py @@ -2,11 +2,12 @@ from admin.api.dependencies import ( AccessRequestServiceDep, + AccessServiceDep, CurrentIdentity, CurrentSession, ) from admin.schemas.access_request import AccessRequestCreate, AccessRequestRead -from admin.schemas.session import Session +from admin.schemas.session import AcceptInvitation, Session router = APIRouter(tags=["session"]) @@ -16,6 +17,16 @@ async def read_session(session: CurrentSession) -> Session: return session +@router.post("/session/accept-invitation", response_model=Session) +async def accept_invitation( + identity: CurrentIdentity, + payload: AcceptInvitation, + service: AccessServiceDep, +) -> Session: + resolved = await service.accept_invitation(identity, payload.token) + return resolved.session + + @router.post( "/session/access-request", response_model=AccessRequestRead, diff --git a/backend/src/admin/cli.py b/backend/src/admin/cli.py index 1d9862d..efe1d5e 100644 --- a/backend/src/admin/cli.py +++ b/backend/src/admin/cli.py @@ -1,16 +1,24 @@ import asyncio +import json from datetime import UTC, datetime, timedelta +from pathlib import Path +import httpx import typer from admin.core.config import Settings, get_settings from admin.core.database import DBConnection, create_pool +from admin.core.email import EmailSender, build_email_sender from admin.domain.permissions import ( PERMISSION_DESCRIPTIONS, ROLE_DEFINITIONS, + ROLE_DEFINITIONS_BY_KEY, Permission, SystemRole, ) +from admin.repositories.invitation import InvitationRepository +from admin.repositories.role import RoleRepository +from admin.repositories.user import UserRepository from admin.services.invitation import generate_token app = typer.Typer(help="generate-admin management commands") @@ -68,7 +76,9 @@ async def _sync_roles(connection: DBConnection) -> None: ) -async def _ensure_owner_invitation(connection: DBConnection, settings: Settings) -> None: +async def _ensure_owner_invitation( + connection: DBConnection, settings: Settings, email_sender: EmailSender +) -> None: email = settings.initial_owner_email.strip().lower() if not email: return @@ -109,6 +119,12 @@ async def _ensure_owner_invitation(connection: DBConnection, settings: Settings) token_hash, datetime.now(UTC) + timedelta(hours=settings.invitation_ttl_hours), ) + await email_sender.send_invitation( + email=email, + role_name=ROLE_DEFINITIONS_BY_KEY[SystemRole.OWNER].name, + token=token, + app_url=settings.frontend_base_url, + ) print(f"owner invitation created for {email}") print(f"invitation token: {token}") @@ -116,13 +132,16 @@ async def _ensure_owner_invitation(connection: DBConnection, settings: Settings) async def _seed() -> None: settings = get_settings() pool = await create_pool(settings.database) + client = httpx.AsyncClient() try: + email_sender = build_email_sender(settings.resend, client) async with pool.acquire() as connection, connection.transaction(): await _sync_permissions(connection) await _sync_roles(connection) - await _ensure_owner_invitation(connection, settings) + await _ensure_owner_invitation(connection, settings, email_sender) print("seed complete") finally: + await client.aclose() await pool.close() @@ -131,5 +150,84 @@ def seed() -> None: asyncio.run(_seed()) +async def _invite(email: str, role_key: str, expires_in_hours: int | None) -> None: + settings = get_settings() + pool = await create_pool(settings.database) + client = httpx.AsyncClient() + try: + email_sender = build_email_sender(settings.resend, client) + async with pool.acquire() as connection, connection.transaction(): + users = UserRepository(connection) + roles = RoleRepository(connection) + invitations = InvitationRepository(connection) + + if await users.get_by_email(email) is not None: + raise typer.BadParameter(f"{email} is already a member") + + role = await roles.get_by_key(role_key) + if role is None: + raise typer.BadParameter(f"no role with key {role_key!r} (run `just seed` first)") + + if await invitations.find_open_for_email(email) is not None: + raise typer.BadParameter(f"an open invitation already exists for {email}") + + token, token_hash = generate_token() + ttl_hours = expires_in_hours or settings.invitation_ttl_hours + expires_at = datetime.now(UTC) + timedelta(hours=ttl_hours) + + await invitations.create( + email=email, + role_id=role.id, + token_hash=token_hash, + invited_by=None, + expires_at=expires_at, + ) + await email_sender.send_invitation( + email=email, role_name=role.name, token=token, app_url=settings.frontend_base_url + ) + + print(f"invitation created for {email} ({role_key})") + print(f"invitation token: {token}") + finally: + await client.aclose() + await pool.close() + + +@app.command() +def invite( + email: str = typer.Argument(..., help="Email address to invite"), + role: str = typer.Option( + ..., "--role", "-r", help="Role key, e.g. owner, admin, or a custom role from `just seed`" + ), + expires_in_hours: int | None = typer.Option( + None, "--expires-in-hours", help="Defaults to INVITATION_TTL_HOURS" + ), +) -> None: + """Create an invitation for local/dev use and print the raw token. + + Skips the permission checks InvitationService.create enforces over the API (delegation + rules, who's allowed to grant what) since this runs with direct DB access, not as a given + actor. The token is only ever shown here — the database only ever stores its hash. + """ + asyncio.run(_invite(email.strip().lower(), role, expires_in_hours)) + + +@app.command() +def openapi(output: Path = Path("../openapi.json")) -> None: + """Write the OpenAPI schema to disk. + + Deliberately does not boot the server: FastAPI can produce the schema from the route table + alone, so codegen works offline and in CI without Postgres or Redis. Output is stable across + runs because the route table and Pydantic field order are, which is what the CI drift check + relies on; keys are left in declaration order rather than sorted so the generated types read + like the models they came from. + """ + from admin.main import create_app + + schema = create_app().openapi() + output.write_text(json.dumps(schema, indent=2) + "\n") + print(f"wrote {output}") + + if __name__ == "__main__": app() diff --git a/backend/src/admin/core/cache.py b/backend/src/admin/core/cache.py index 7f667f0..30d9d80 100644 --- a/backend/src/admin/core/cache.py +++ b/backend/src/admin/core/cache.py @@ -5,6 +5,7 @@ from pydantic import TypeAdapter from redis.asyncio import Redis +from redis.exceptions import RedisError from admin.core.logging import get_logger @@ -66,7 +67,10 @@ async def version(self, namespace: CacheNamespace) -> int: return int(raw) if raw else 0 async def bump(self, namespace: CacheNamespace) -> None: - await self._client.incr(self._version_key(namespace)) + try: + await self._client.incr(self._version_key(namespace)) + except RedisError as error: + logger.warning("cache_bump_failed", namespace=namespace.value, error=str(error)) async def close(self) -> None: await self._client.aclose() @@ -80,25 +84,39 @@ async def fetch( adapter: TypeAdapter[Any], ttl: float | None = None, ) -> Any: - version = await self.version(namespace) - qualified = f"{KEY_PREFIX}:{namespace.value}:v{version}:{key}" + try: + version = await self.version(namespace) + qualified = f"{KEY_PREFIX}:{namespace.value}:v{version}:{key}" + cached = await self._client.get(qualified) + except RedisError as error: + logger.warning("cache_read_failed", namespace=namespace.value, error=str(error)) + return await loader() - cached = await self._client.get(qualified) if cached is not None: return adapter.validate_json(cached) lock = await self._flight.lock_for(qualified) async with lock: - cached = await self._client.get(qualified) + try: + cached = await self._client.get(qualified) + except RedisError as error: + logger.warning("cache_read_failed", namespace=namespace.value, error=str(error)) + return await loader() + if cached is not None: return adapter.validate_json(cached) value = await loader() - await self._client.set( - qualified, - adapter.dump_json(value), - ex=int(ttl or self._default_ttl), - ) + + try: + await self._client.set( + qualified, + adapter.dump_json(value), + ex=int(ttl or self._default_ttl), + ) + except RedisError as error: + logger.warning("cache_write_failed", namespace=namespace.value, error=str(error)) + return value @@ -109,9 +127,8 @@ async def build_cache(redis_url: str, *, default_ttl: float = DEFAULT_TTL_SECOND client: Redis = Redis.from_url(redis_url, decode_responses=False) try: await client.ping() - except Exception as error: - await client.aclose() - raise RuntimeError(f"could not connect to redis at {redis_url}") from error + logger.info("cache_redis", url=redis_url) + except RedisError as error: + logger.warning("cache_redis_unreachable", url=redis_url, error=str(error)) - logger.info("cache_redis", url=redis_url) return RedisCache(client, default_ttl=default_ttl) diff --git a/backend/src/admin/core/config.py b/backend/src/admin/core/config.py index b2e2a80..60d5b8a 100644 --- a/backend/src/admin/core/config.py +++ b/backend/src/admin/core/config.py @@ -101,16 +101,27 @@ def public_url_for(self, key: str) -> str: return f"{base.rstrip('/')}/{key}" +class ResendConfig(BaseConfig): + api_key: SecretStr = Field(default=SecretStr(""), alias="RESEND_API_KEY") + from_email: str = Field(default="", alias="RESEND_FROM_EMAIL") + + @property + def is_configured(self) -> bool: + return bool(self.api_key.get_secret_value() and self.from_email) + + class Settings(BaseConfig): app: AppConfig = Field(default_factory=AppConfig) database: DatabaseConfig = Field(default_factory=DatabaseConfig) entra: EntraConfig = Field(default_factory=EntraConfig) storage: StorageConfig = Field(default_factory=StorageConfig) + resend: ResendConfig = Field(default_factory=ResendConfig) cors_allowed_origins_raw: str = Field(default="", alias="CORS_ALLOWED_ORIGINS") redis_url: str = Field(default="", alias="REDIS_URL") initial_owner_email: str = "" invitation_ttl_hours: int = 336 + frontend_base_url: str = Field(default="http://localhost:3000", alias="FRONTEND_BASE_URL") @property def cors_allowed_origins(self) -> list[str]: diff --git a/backend/src/admin/core/database.py b/backend/src/admin/core/database.py index 2bff5f2..865d6d6 100644 --- a/backend/src/admin/core/database.py +++ b/backend/src/admin/core/database.py @@ -6,9 +6,6 @@ from admin.core.config import DatabaseConfig -# Pool.acquire() yields a PoolConnectionProxy, which forwards to Connection at runtime via -# metaclass delegation but is not a subclass of it. Anything that just runs queries should -# accept either. type DBConnection = asyncpg.Connection | asyncpg.pool.PoolConnectionProxy ASYNCPG_SCHEME = "postgresql://" diff --git a/backend/src/admin/core/email.py b/backend/src/admin/core/email.py new file mode 100644 index 0000000..053699b --- /dev/null +++ b/backend/src/admin/core/email.py @@ -0,0 +1,92 @@ +from typing import Protocol + +import httpx + +from admin.core.config import ResendConfig +from admin.core.logging import get_logger + +logger = get_logger(__name__) + +RESEND_API_URL = "https://api.resend.com/emails" + +MONO_STACK = "'Courier New', Consolas, monospace" +SANS_STACK = "Helvetica, Arial, sans-serif" +INK = "#000000" +PAPER = "#ffffff" + + +def invitation_email_html(*, role_name: str, accept_url: str) -> str: + return f""" +
+
+ Generate Admin +
+

+ You've been invited to Generate Admin as {role_name}. +

+ + Accept invitation + +
+ """ + + +class EmailSender(Protocol): + async def send_invitation( + self, *, email: str, role_name: str, token: str, app_url: str + ) -> None: ... + + +class ResendEmailSender: + def __init__(self, config: ResendConfig, client: httpx.AsyncClient) -> None: + self._config = config + self._client = client + + async def send_invitation( + self, *, email: str, role_name: str, token: str, app_url: str + ) -> None: + accept_url = f"{app_url}/?invite_token={token}" + try: + response = await self._client.post( + RESEND_API_URL, + headers={"Authorization": f"Bearer {self._config.api_key.get_secret_value()}"}, + json={ + "from": self._config.from_email, + "to": email, + "subject": "You're invited to Generate Admin", + "html": invitation_email_html(role_name=role_name, accept_url=accept_url), + }, + ) + response.raise_for_status() + except httpx.HTTPStatusError as error: + logger.warning( + "invitation_email_failed", + email=email, + status=error.response.status_code, + body=error.response.text, + ) + except httpx.HTTPError as error: + logger.warning("invitation_email_failed", email=email, error=str(error)) + + +class NullEmailSender: + async def send_invitation( + self, *, email: str, role_name: str, token: str, app_url: str + ) -> None: + logger.warning( + "resend_not_configured", + detail=f"invitation email to {email} was not sent; accept at " + f"{app_url}/?invite_token={token}", + ) + + +def build_email_sender(config: ResendConfig, client: httpx.AsyncClient) -> EmailSender: + if config.is_configured: + return ResendEmailSender(config, client) + return NullEmailSender() diff --git a/backend/src/admin/domain/enums.py b/backend/src/admin/domain/enums.py index 2a4bbc6..b567e2c 100644 --- a/backend/src/admin/domain/enums.py +++ b/backend/src/admin/domain/enums.py @@ -8,6 +8,7 @@ class UserStatus(StrEnum): class AccessState(StrEnum): NO_ACCESS = "no_access" + INVITED = "invited" PENDING = "pending" DENIED = "denied" ACTIVE = "active" diff --git a/backend/src/admin/main.py b/backend/src/admin/main.py index 5e5a66f..129f694 100644 --- a/backend/src/admin/main.py +++ b/backend/src/admin/main.py @@ -10,6 +10,7 @@ from admin.core.cache import build_cache from admin.core.config import Settings, get_settings from admin.core.database import create_pool +from admin.core.email import build_email_sender from admin.core.errors import DomainError from admin.core.logging import configure_logging, get_logger from admin.core.openapi import operation_id_for @@ -41,6 +42,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.state.cache = cache app.state.storage = S3Storage(settings.storage, cache) app.state.token_verifier = build_token_verifier(settings.entra, client) + app.state.email_sender = build_email_sender(settings.resend, client) logger.info("application_started", environment=settings.app.environment.value) diff --git a/backend/src/admin/repositories/invitation.py b/backend/src/admin/repositories/invitation.py index 1c1c39c..bb60e6f 100644 --- a/backend/src/admin/repositories/invitation.py +++ b/backend/src/admin/repositories/invitation.py @@ -70,6 +70,23 @@ async def find_open_for_email(self, email: str) -> InvitationRead | None: ) return InvitationRead.from_optional_row(row) + async def find_open_for_email_and_token( + self, email: str, token_hash: str + ) -> InvitationRead | None: + row = await self.connection.fetchrow( + f""" + {INVITATION_SELECT} + WHERE i.email = $1 + AND i.token_hash = $2 + AND i.accepted_at IS NULL + AND i.revoked_at IS NULL + AND i.expires_at > now() + """, + email.lower(), + token_hash, + ) + return InvitationRead.from_optional_row(row) + async def list_open(self) -> list[InvitationRead]: rows = await self.connection.fetch( f""" diff --git a/backend/src/admin/schemas/session.py b/backend/src/admin/schemas/session.py index 3cde573..ba3d1b6 100644 --- a/backend/src/admin/schemas/session.py +++ b/backend/src/admin/schemas/session.py @@ -3,7 +3,7 @@ from pydantic import EmailStr, Field from admin.domain.enums import AccessState -from admin.schemas.base import ReadDTO +from admin.schemas.base import ReadDTO, RequestDTO from admin.schemas.user import UserRead @@ -13,6 +13,10 @@ class Identity(ReadDTO): name: str +class AcceptInvitation(RequestDTO): + token: str = Field(min_length=1) + + class Session(ReadDTO): access_state: AccessState identity: Identity diff --git a/backend/src/admin/services/access.py b/backend/src/admin/services/access.py index aaa5018..677c206 100644 --- a/backend/src/admin/services/access.py +++ b/backend/src/admin/services/access.py @@ -1,4 +1,5 @@ from admin.core.audit import AuditLog +from admin.core.errors import NotFoundError from admin.domain.access import PermissionSet, ResolvedAccess from admin.domain.enums import ( AccessRequestStatus, @@ -14,6 +15,7 @@ from admin.schemas.invitation import InvitationRead from admin.schemas.session import Identity, Session from admin.schemas.user import UserRead +from admin.services.invitation import hash_token class AccessService: @@ -35,11 +37,6 @@ def __init__( async def resolve(self, identity: Identity) -> ResolvedAccess: user = await self._users.get_by_entra_object_id(identity.entra_object_id) - if user is None: - invitation = await self._invitations.find_open_for_email(identity.email) - if invitation is not None: - user = await self._accept_invitation(identity, invitation) - if user is None: return ResolvedAccess( session=Session( @@ -51,7 +48,20 @@ async def resolve(self, identity: Identity) -> ResolvedAccess: return await self._session_for_user(identity, user) + async def accept_invitation(self, identity: Identity, token: str) -> ResolvedAccess: + invitation = await self._invitations.find_open_for_email_and_token( + identity.email, hash_token(token) + ) + if invitation is None: + raise NotFoundError("no matching invitation for that email and token") + + user = await self._accept_invitation(identity, invitation) + return await self._session_for_user(identity, user) + async def _state_without_account(self, identity: Identity) -> AccessState: + if await self._invitations.find_open_for_email(identity.email) is not None: + return AccessState.INVITED + request = await self._access_requests.find_latest_for_email(identity.email) if request is None: return AccessState.NO_ACCESS diff --git a/backend/src/admin/services/invitation.py b/backend/src/admin/services/invitation.py index 784069b..bc401cd 100644 --- a/backend/src/admin/services/invitation.py +++ b/backend/src/admin/services/invitation.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime, timedelta from admin.core.audit import AuditLog +from admin.core.email import EmailSender from admin.core.errors import ConflictError, NotFoundError, ValidationError from admin.domain.access import PermissionSet from admin.domain.enums import AuditAction @@ -18,9 +19,13 @@ TOKEN_BYTES = 32 +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + def generate_token() -> tuple[str, str]: token = secrets.token_urlsafe(TOKEN_BYTES) - return token, hashlib.sha256(token.encode()).hexdigest() + return token, hash_token(token) class InvitationService: @@ -31,13 +36,17 @@ def __init__( roles: RoleRepository, users: UserRepository, audit: AuditLog, + email_sender: EmailSender, default_ttl_hours: int, + frontend_base_url: str, ) -> None: self._invitations = invitations self._roles = roles self._users = users self._audit = audit + self._email_sender = email_sender self._default_ttl_hours = default_ttl_hours + self._frontend_base_url = frontend_base_url async def create( self, @@ -83,6 +92,10 @@ async def create( ) ) + await self._email_sender.send_invitation( + email=email, role_name=role.name, token=token, app_url=self._frontend_base_url + ) + return InvitationCreated(invitation=invitation, token=token) async def list_open(self) -> list[InvitationRead]: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 703a4d3..808a8c5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,7 +3,9 @@ import pytest from fastapi.testclient import TestClient +from admin.api.dependencies import get_token_verifier from admin.core.config import Settings, get_settings +from admin.core.security import LocalTokenVerifier from admin.main import create_app @@ -15,5 +17,10 @@ def settings() -> Settings: @pytest.fixture(scope="session") def client() -> Iterator[TestClient]: """Runs the real lifespan, so this needs Postgres and Redis to be up.""" - with TestClient(create_app()) as test_client: + app = create_app() + # Pin the local verifier. Otherwise the suite silently depends on whether the developer's + # .env has ENTRA_API_CLIENT_ID filled in, and the base64 tokens the tests mint stop working + # the moment real Entra config is present. + app.dependency_overrides[get_token_verifier] = LocalTokenVerifier + with TestClient(app) as test_client: yield test_client diff --git a/backend/tests/test_access.py b/backend/tests/test_access.py index c564e23..0f2404f 100644 --- a/backend/tests/test_access.py +++ b/backend/tests/test_access.py @@ -1,9 +1,14 @@ +import asyncio import base64 +import hashlib import json import uuid from fastapi.testclient import TestClient +from admin.core.config import Settings +from admin.core.database import create_pool + def local_token(email: str, object_id: uuid.UUID) -> str: claims = json.dumps({"oid": str(object_id), "email": email, "name": "Test Person"}) @@ -14,6 +19,37 @@ def auth_header(email: str, object_id: uuid.UUID) -> dict[str, str]: return {"Authorization": f"Bearer {local_token(email, object_id)}"} +async def _create_invitation(settings: Settings, *, email: str, token: str) -> None: + pool = await create_pool(settings.database) + try: + async with pool.acquire() as connection: + role_id = await connection.fetchval( + """ + INSERT INTO roles (key, name, is_system) + VALUES ($1, $2, FALSE) + ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name + RETURNING id + """, + "test-invite-role", + "Test Invite Role", + ) + await connection.execute( + """ + INSERT INTO invitations (email, role_id, token_hash, expires_at) + VALUES ($1, $2, $3, now() + interval '1 hour') + """, + email, + role_id, + hashlib.sha256(token.encode()).hexdigest(), + ) + finally: + await pool.close() + + +def create_invitation(settings: Settings, *, email: str, token: str) -> None: + asyncio.run(_create_invitation(settings, email=email, token=token)) + + def test_session_requires_a_token(client: TestClient) -> None: response = client.get("/api/v1/session") @@ -54,6 +90,63 @@ def test_access_request_moves_the_session_to_pending(client: TestClient) -> None assert session.json()["access_state"] == "pending" +def test_invited_stranger_sees_invited_state(client: TestClient, settings: Settings) -> None: + object_id = uuid.uuid4() + email = f"{object_id}@example.com" + create_invitation(settings, email=email, token=f"token-{object_id}") + + response = client.get("/api/v1/session", headers=auth_header(email, object_id)) + + assert response.status_code == 200 + body = response.json() + assert body["access_state"] == "invited" + assert body["user"] is None + + +def test_wrong_token_does_not_accept_the_invitation(client: TestClient, settings: Settings) -> None: + object_id = uuid.uuid4() + email = f"{object_id}@example.com" + create_invitation(settings, email=email, token=f"correct-{object_id}") + + response = client.post( + "/api/v1/session/accept-invitation", + headers=auth_header(email, object_id), + json={"token": "wrong-token"}, + ) + + assert response.status_code == 404 + + session = client.get("/api/v1/session", headers=auth_header(email, object_id)) + assert session.json()["access_state"] == "invited" + + +def test_accept_invitation_provisions_the_user(client: TestClient, settings: Settings) -> None: + object_id = uuid.uuid4() + email = f"{object_id}@example.com" + token = f"correct-{object_id}" + create_invitation(settings, email=email, token=token) + + response = client.post( + "/api/v1/session/accept-invitation", + headers=auth_header(email, object_id), + json={"token": token}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["user"]["email"] == email + + session = client.get("/api/v1/session", headers=auth_header(email, object_id)) + assert session.json()["user"]["email"] == email + + repeated = client.post( + "/api/v1/session/accept-invitation", + headers=auth_header(email, object_id), + json={"token": token}, + ) + assert repeated.status_code == 404 + + def test_unprovisioned_caller_cannot_read_roles(client: TestClient) -> None: object_id = uuid.uuid4() headers = auth_header(f"{object_id}@example.com", object_id) diff --git a/backend/tests/test_cache.py b/backend/tests/test_cache.py index 8366f90..0a53403 100644 --- a/backend/tests/test_cache.py +++ b/backend/tests/test_cache.py @@ -61,6 +61,45 @@ async def loader() -> str: assert calls == 1 +async def test_unreachable_redis_still_builds_a_cache() -> None: + """Booting without a reachable cache is slow; refusing to boot is an outage.""" + unreachable = await build_cache("redis://127.0.0.1:1/0") + assert unreachable is not None + await unreachable.close() + + +async def test_fetch_falls_back_to_the_loader_when_redis_is_down() -> None: + unreachable = await build_cache("redis://127.0.0.1:1/0") + calls = 0 + + async def loader() -> str: + nonlocal calls + calls += 1 + return "value" + + try: + first = await unreachable.fetch( + CacheNamespace.CONTENT, unique_key(), loader, adapter=STRING_ADAPTER + ) + second = await unreachable.fetch( + CacheNamespace.CONTENT, unique_key(), loader, adapter=STRING_ADAPTER + ) + finally: + await unreachable.close() + + # Every call recomputes, but nothing raises. + assert first == second == "value" + assert calls == 2 + + +async def test_bump_does_not_raise_when_redis_is_down() -> None: + unreachable = await build_cache("redis://127.0.0.1:1/0") + try: + await unreachable.bump(CacheNamespace.ROLES) + finally: + await unreachable.close() + + async def test_bump_invalidates_the_whole_namespace(cache: Cache) -> None: key = unique_key() calls = 0 diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..52d8d39 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,37 @@ +name: generate-admin-test + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: generate_admin + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d generate_admin"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + backend-test: + build: + context: ./backend + target: test + environment: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/generate_admin + REDIS_URL: redis://redis:6379/0 + APP_ENVIRONMENT: local + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..aa4ca61 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +# All NEXT_PUBLIC_* values are baked into the browser bundle. That is fine here: +# client id, tenant id and scope are public identifiers, and the SPA uses PKCE, no secret. +NEXT_PUBLIC_ENTRA_TENANT_ID= +NEXT_PUBLIC_ENTRA_CLIENT_ID= +NEXT_PUBLIC_API_SCOPE=api:///access_as_user +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..7b8da95 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..6615fc3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,81 @@ +# Generate Admin — frontend + +Next.js admin console for the [backend API](../backend). Sign-in is Entra ID (MSAL, SPA/PKCE +flow, no client secret); once signed in, the app calls the API using the typed client in +[`@generatenu/api`](../packages/api). + +## Requirements + +- Node 22+ (`.nvmrc` at the repo root; `nvm use` picks it up) +- The backend running locally — see the [root README](../README.md) (`just up && just migrate && just seed && just dev`) + +## Setup + +```bash +just frontend-install +cp frontend/.env.example frontend/.env.local +``` + +Fill in `frontend/.env.local`: + +| Variable | Where it comes from | +| ------------------------------ | -------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_ENTRA_TENANT_ID` | The Entra tenant ID (same tenant the backend's `ENTRA_TENANT_ID` uses) | +| `NEXT_PUBLIC_ENTRA_CLIENT_ID` | Client ID of this app's Entra app registration (SPA platform, redirect URI = `http://localhost:3000`) | +| `NEXT_PUBLIC_API_SCOPE` | `api:///access_as_user` — the exposed API scope, not Graph | +| `NEXT_PUBLIC_API_BASE_URL` | `http://localhost:8000` for local dev | + +These are all public identifiers (no secret — MSAL uses PKCE), so it's normal for +`NEXT_PUBLIC_*` values to end up in the browser bundle. If you don't have an app registration +yet, ask a teammate who's set one up, or create one in the Azure Portal under the same tenant +as the backend's. + +Then: + +```bash +just frontend-dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +## How auth works + +- `src/auth/msal.ts` configures MSAL and requests `NEXT_PUBLIC_API_SCOPE` (not a Graph scope) — + that's what makes Entra mint an access token whose `aud` is the backend's client ID, which is + what `TokenVerifier` on the backend checks. +- `src/app/providers.tsx` wires up `MsalProvider`, a React Query `QueryClient`, and calls + `configureApi({ baseUrl, getToken })` from `@generatenu/api` so every generated hook + automatically attaches a bearer token. +- `src/auth/SessionGate.tsx` wraps the whole app (in `layout.tsx`) and is the only place that + decides what to render based on `GET /session`'s `access_state`: + + | `access_state` | What's shown | + | -------------- | ------------ | + | not signed in | Sign-in button | + | `no_access` | A "request access" form (`POST /session/access-request`) — no invitation and no prior request | + | `pending` | "your request is awaiting review" | + | `denied` | "your request was denied" | + | `suspended` | "your account has been suspended" | + | `no_roles` | The app, plus a banner — signed in and provisioned, but no role grants a permission yet | + | `active` | The app | + + Signing in with an email that has an open invitation provisions the user automatically (the + backend accepts the invitation the first time `GET /session` is called for that identity) — + there's no separate "create account" step. + + Anything rendered inside `SessionGate` can call `useSession()` + (`src/auth/session-context.tsx`) to get the current `Session`, guaranteed to have a non-null + `user`. + +## Commands + +| Recipe | What it does | +| ------------------------ | -------------------------------------------------------------------------- | +| `just frontend-install` | `npm install` at the repo root (this is an npm workspace, not standalone) | +| `just frontend-dev` | Dev server (Turbopack) | +| `just frontend-build` | Production build (also type-checks) | +| `just frontend-lint` | ESLint | +| `just gen` | Regenerate `@generatenu/api` from the backend's committed `openapi.json` | + +Regenerate the API client whenever backend endpoints change and commit the diff — CI +(`backend-ci.yml`'s `contract` job) fails the build if `packages/api/src/generated` is stale. diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..5cf2fba --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + agentRules: false, + transpilePackages: ["@generatenu/api"], +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c917740 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@azure/msal-browser": "^5.18.0", + "@azure/msal-react": "^5.5.5", + "@tanstack/react-query": "^5.101.4", + "next": "16.3.1", + "react": "19.2.8", + "react-dom": "19.2.8", + "@generatenu/api": "*" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.3.1", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..920a358 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,25 @@ +@import "tailwindcss"; + +:root { + --color-ink: #000000; + --color-paper: #ffffff; + --color-accent: #1477f8; + --color-highlight: #ffbf3c; + --color-muted: #6b6b6b; +} + +@theme inline { + --color-ink: var(--color-ink); + --color-paper: var(--color-paper); + --color-accent: var(--color-accent); + --color-highlight: var(--color-highlight); + --color-muted: var(--color-muted); + --font-sans: var(--font-outfit); + --font-mono: var(--font-space-mono); + --shadow-hard: 6px 6px 0 0 var(--color-ink); + --shadow-hard-sm: 4px 4px 0 0 var(--color-ink); +} + +body { + font-family: var(--font-sans), sans-serif; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..b5efb2e --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Outfit, Space_Mono } from "next/font/google"; +import "./globals.css"; +import { Providers } from "./providers"; +import { SessionGate } from "@/auth/SessionGate"; + +const outfit = Outfit({ + variable: "--font-outfit", + subsets: ["latin"], + weight: ["300", "400", "500", "600"], +}); + +const spaceMono = Space_Mono({ + variable: "--font-space-mono", + subsets: ["latin"], + weight: ["400", "700"], +}); + +export const metadata: Metadata = { + title: "Generate Admin", + description: "Northeastern Generate admin console", +}; + +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + + + {children} + + + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..8aa1855 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useSession } from "@/auth/session-context"; +import { Badge, Card, Heading } from "@/components/ui"; + +export default function Home() { + const session = useSession(); + const user = session.user; + + return ( +
+
+ Generate Admin +

Welcome, {user?.name}

+
+ + +
+ + Account + +

{user?.email}

+
+ +
+ + Roles + +
+ {user?.role_assignments?.length ? ( + user.role_assignments.map((assignment) => ( + {assignment.role.name} + )) + ) : ( +

No roles assigned

+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx new file mode 100644 index 0000000..c9a31a6 --- /dev/null +++ b/frontend/src/app/providers.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { MsalProvider } from "@azure/msal-react"; +import { EventType, type AuthenticationResult } from "@azure/msal-browser"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { configureApi } from "@generatenu/api"; + +import { getApiToken, msalInstance } from "@/auth/msal"; +import { captureInviteTokenFromUrl } from "@/auth/invite-token"; + +configureApi({ + baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000", + getToken: getApiToken, +}); + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + const [ready, setReady] = useState(false); + + useEffect(() => { + captureInviteTokenFromUrl(); + + msalInstance + .initialize() + .then(() => msalInstance.handleRedirectPromise()) + .then((result) => { + if (result?.account) msalInstance.setActiveAccount(result.account); + else if (!msalInstance.getActiveAccount()) { + const [first] = msalInstance.getAllAccounts(); + if (first) msalInstance.setActiveAccount(first); + } + setReady(true); + }); + + const callbackId = msalInstance.addEventCallback((event) => { + if (event.eventType === EventType.LOGIN_SUCCESS) { + msalInstance.setActiveAccount((event.payload as AuthenticationResult).account); + } + }); + + return () => { + if (callbackId) msalInstance.removeEventCallback(callbackId); + }; + }, []); + + if (!ready) return
Loading…
; + + return ( + + {children} + + ); +} diff --git a/frontend/src/auth/AcceptInviteForm.tsx b/frontend/src/auth/AcceptInviteForm.tsx new file mode 100644 index 0000000..03cee14 --- /dev/null +++ b/frontend/src/auth/AcceptInviteForm.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getReadSessionQueryKey, useAcceptInvitation } from "@generatenu/api"; +import type { Identity } from "@generatenu/api"; +import { Button, Heading, Input } from "@/components/ui"; +import { clearPendingInviteToken, peekPendingInviteToken } from "@/auth/invite-token"; + +export function AcceptInviteForm({ identity }: { identity: Identity }) { + const [token, setToken] = useState(() => peekPendingInviteToken()); + const queryClient = useQueryClient(); + const autoSubmitted = useRef(false); + + const mutation = useAcceptInvitation({ + mutation: { + onSuccess: () => queryClient.invalidateQueries({ queryKey: getReadSessionQueryKey() }), + }, + }); + + useEffect(() => { + if (autoSubmitted.current || !token) return; + autoSubmitted.current = true; + clearPendingInviteToken(); + mutation.mutate({ data: { token } }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ Accept your invitation +

+ Signed in as {identity.email}. Enter the invitation code you were sent. +

+
+ + setToken(event.target.value)} + disabled={mutation.isPending} + /> + + + + {mutation.isError && ( +

+ {mutation.error instanceof Error ? mutation.error.message : "Something went wrong."} +

+ )} +
+ ); +} diff --git a/frontend/src/auth/AccessRequestForm.tsx b/frontend/src/auth/AccessRequestForm.tsx new file mode 100644 index 0000000..af3cc98 --- /dev/null +++ b/frontend/src/auth/AccessRequestForm.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getReadSessionQueryKey, useRequestAccess } from "@generatenu/api"; +import type { Identity } from "@generatenu/api"; +import { Button, Heading, Textarea } from "@/components/ui"; + +export function AccessRequestForm({ identity }: { identity: Identity }) { + const [message, setMessage] = useState(""); + const queryClient = useQueryClient(); + + const mutation = useRequestAccess({ + mutation: { + onSuccess: () => queryClient.invalidateQueries({ queryKey: getReadSessionQueryKey() }), + }, + }); + + return ( +
+
+ Request access +

+ Signed in as {identity.email}. You haven't been invited to Generate Admin yet. Send + a request and an admin will review it. +

+
+ +