Enforce recipient-bound single-use invites and fix issue card layout; clean release tooling
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
.venv/
|
||||
**/.pytest_cache/
|
||||
stitch_magent_media_operations_redesign/
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
bootstrap-admin.json
|
||||
release.tar
|
||||
*.log
|
||||
|
||||
@@ -11,3 +11,10 @@ backend/.pytest_cache/
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
*.log
|
||||
**/.pytest_cache/
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
+51
-54
@@ -1,64 +1,61 @@
|
||||
# Fresh production setup
|
||||
# Production
|
||||
|
||||
Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and
|
||||
`docker-compose.production.yml`. The legacy `prod` deployment and beta are not
|
||||
overwritten. Main runs CI verification; production activation is deliberately
|
||||
manual during the initial cutover.
|
||||
Magent runs as one combined frontend/API image: `rephl3xnz/magent`.
|
||||
The root `Dockerfile` is the supported build entry point. Source releases come
|
||||
from `main`; use `prod-<short-commit>` tags to identify an exact release.
|
||||
|
||||
Only API connection URLs/credentials and SMTP configuration are exported by
|
||||
`scripts/prepare_production_settings.py`. It reads the source's effective settings,
|
||||
uses an explicit allowlist, refuses existing output directories, and creates
|
||||
private files. It never copies a database, users, invite codes, issues, history,
|
||||
tokens, sessions, branding or notification templates. A new bootstrap admin and
|
||||
JWT secret are generated. Retrieve the bootstrap credentials from the protected
|
||||
`bootstrap-admin.json` on the server; never commit them.
|
||||
## Live deployment
|
||||
|
||||
The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables
|
||||
`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic
|
||||
imports and repair emails. The cover is not an authentication/security boundary;
|
||||
normal API authentication remains in force. Administrators can use `/login`.
|
||||
- Host: GRZ-DKR01 (`10.30.1.81`).
|
||||
- Container and Compose service: `magent`; Compose project: `arrstack`.
|
||||
- Compose file: `/home/zak/grizzlystack/arrstack/docker-compose.yml`.
|
||||
- Persistent data: `/home/zak/grizzlystack/arrstack/magent/data` → `/app/data`.
|
||||
- Public URL: `https://magent.grizzlyflix.co.nz`.
|
||||
- Caddy runs on AMS-CAD01 and proxies production to `10.30.1.81:3002`.
|
||||
- Beta remains separate on AMS-DEV01. Do not overwrite it or change its routes.
|
||||
|
||||
Run `docker compose -f docker-compose.production.yml up -d --build` from the
|
||||
production directory. Caddy should proxy this hostname to `10.30.1.32:3200`;
|
||||
Next forwards `/api` internally. The backend health port is localhost-only at
|
||||
8200. Do not alter beta's route or other Caddy sites.
|
||||
## Release checklist
|
||||
|
||||
Before public activation, validate Caddy config, save its existing configuration,
|
||||
verify HTTPS, admin login, connection diagnostics and the empty-client-data state.
|
||||
Do not send SMTP tests without approval. Keep the old upstream for rollback.
|
||||
1. Run the backend tests and frontend production build. Review only the intended
|
||||
changes, then commit and push `main`.
|
||||
2. Build from a clean source export using the root Dockerfile. Never include
|
||||
`.env`, databases or bootstrap credentials in the build context.
|
||||
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
|
||||
Confirm their digests match.
|
||||
4. Pull the new image before stopping production. Keep the old image under a
|
||||
rollback tag and back up the current Compose configuration.
|
||||
5. Briefly stop only `magent`, then back up its complete data directory so SQLite
|
||||
and its WAL files are consistent. Protect backups: they contain private data.
|
||||
6. Recreate only this service with `docker compose -p arrstack -f
|
||||
/home/zak/grizzlystack/arrstack/docker-compose.yml up -d --no-deps --no-build magent`.
|
||||
Confirm that Compose selects the intended image before running this command.
|
||||
7. Check container health, the API `/health` endpoint, public login, the changed
|
||||
feature, database integrity and account counts. Do not trigger bulk permission
|
||||
changes, email sends or user imports as a deployment smoke test.
|
||||
|
||||
At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`,
|
||||
then recreate the container. External service records can then be imported through
|
||||
normal synchronization; no beta client data is migrated. Review quality profiles,
|
||||
root folders, invite policy and notification rules in admin settings before use.
|
||||
For rollback, select the saved image and recreate only Magent. Restore data only
|
||||
if needed; doing so can discard activity since the backup. Never restore a whole
|
||||
shared Compose or Caddy file without checking for unrelated changes first.
|
||||
|
||||
## Initial cutover — 7 September 2026
|
||||
## Build metadata
|
||||
|
||||
- Public HTTPS cover and `/api/health` verified after cutover.
|
||||
- Caddy: AMS-CAD01, `/etc/caddy/Caddyfile`, systemd `caddy.service`.
|
||||
- SSH worked via `10.30.40.254` using `HostKeyAlias=10.30.41.254`.
|
||||
- Only the `magent.grizzlyflix.co.nz` upstream changed, from
|
||||
`10.30.1.81:3002` to `10.30.1.32:3200`. Both beta blocks were unchanged.
|
||||
- Rollback configuration: `/etc/caddy/Caddyfile.bak-magent-prod-20260907T0130`.
|
||||
Restore it, run `sudo caddy validate --config /etc/caddy/Caddyfile`, then
|
||||
`sudo systemctl reload caddy`. Review subsequent edits before restoring the
|
||||
whole file; the old application was not stopped or deleted.
|
||||
- Initial database: one newly generated bootstrap admin; zero invites, issues,
|
||||
cached requests, actions or snapshots. Login smoke-testing subsequently creates
|
||||
normal admin login activity only.
|
||||
- Retrieve `/home/zak/magent-production/bootstrap-admin.json` securely on
|
||||
AMS-DEV01. Sign in at `/login`, then open `/admin` while the cover is active.
|
||||
- No SMTP message was sent as part of validation. Background jobs remain paused.
|
||||
`.build_number` and `backend/app/build_info.py` currently hold the same legacy
|
||||
display build number as the frontend package files. `.env` should have exactly
|
||||
one `BUILD_NUMBER` assignment, not a history of previous releases. Docker release
|
||||
tags identify the deployed source commit independently of this display value.
|
||||
|
||||
## Cover resilience update
|
||||
`scripts/process1.ps1` is a local development workflow: it updates metadata,
|
||||
runs tests, rebuilds local Docker, and can commit changes/send Discord messages.
|
||||
It is **not** the production deployment command. Its build-number helper can be
|
||||
tested safely with `powershell -File scripts/test_env_build_number.ps1`.
|
||||
|
||||
The application host subsequently became unreachable over TCP from Caddy (both
|
||||
3100 and 3200 timed out, despite responding to ping). The cover is now served
|
||||
directly by Caddy from `/var/lib/caddy/magent-cover/index.html`, sourced from
|
||||
`docker/coming-soon.html`, for `/`, `/coming-soon` and `/coming-soon/`.
|
||||
It needs no application server, JavaScript, API or external assets.
|
||||
Other paths retain the production reverse proxy. Full launch now also requires
|
||||
removing the `@landing`/static `handle` block from the production Caddy site once
|
||||
upstream connectivity is stable; the environment switch alone is insufficient.
|
||||
Pre-static configuration backup:
|
||||
`/etc/caddy/Caddyfile.bak-magent-static-20260907T0145`.
|
||||
## Fresh instances and historical notes
|
||||
|
||||
`scripts/prepare_production_settings.py` exports only allowlisted connection and
|
||||
SMTP settings for a fresh instance. Do not use it to replace a live database.
|
||||
`docker-compose.production.yml` is the separate fresh-instance template, not the
|
||||
live GRZ-DKR01 Compose file. `docker-compose.hub.yml` is the generic Docker Hub
|
||||
template; `docker-compose.yml` builds locally; `docker-compose.beta.yml` serves beta.
|
||||
|
||||
The temporary AMS-DEV01 setup and coming-soon cutover are retained under
|
||||
[archived cutover notes](docs/archive/production-cutover-2026-09-07.md).
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
COPY data/branding /app/data/branding
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+26
-1
@@ -1573,7 +1573,7 @@ def delete_user_profile(profile_id: int) -> bool:
|
||||
|
||||
|
||||
def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
|
||||
max_uses = row[6]
|
||||
max_uses = 1 if row[10] else row[6]
|
||||
use_count = int(row[7] or 0)
|
||||
expires_at = row[9]
|
||||
is_expired = _is_datetime_in_past(expires_at)
|
||||
@@ -1657,6 +1657,8 @@ def create_signup_invite(
|
||||
recipient_email: Optional[str] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if recipient_email:
|
||||
max_uses = 1
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -1714,6 +1716,11 @@ def update_signup_invite(
|
||||
expires_at: Optional[str],
|
||||
recipient_email: Optional[str],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
existing = get_signup_invite_by_id(invite_id)
|
||||
if recipient_email or (existing and existing.get('recipient_email')):
|
||||
max_uses = 1
|
||||
if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'):
|
||||
raise ValueError('A used email invitation cannot be reassigned.')
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -1751,6 +1758,24 @@ def delete_signup_invite(invite_id: int) -> bool:
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def reserve_signup_invite_use(invite_id: int) -> bool:
|
||||
"""Atomically reserve capacity before any remote account is provisioned."""
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute('''
|
||||
UPDATE signup_invites SET use_count = use_count + 1
|
||||
WHERE id = ? AND enabled = 1
|
||||
AND (expires_at IS NULL OR julianday(expires_at) > julianday('now'))
|
||||
AND ((recipient_email IS NOT NULL AND recipient_email != '' AND use_count < 1)
|
||||
OR ((recipient_email IS NULL OR recipient_email = '') AND (max_uses IS NULL OR use_count < max_uses)))
|
||||
''', (invite_id,))
|
||||
return cursor.rowcount == 1
|
||||
|
||||
|
||||
def release_signup_invite_use(invite_id: int) -> None:
|
||||
with _connect() as conn:
|
||||
conn.execute('UPDATE signup_invites SET use_count = MAX(0, use_count - 1) WHERE id = ?', (invite_id,))
|
||||
|
||||
|
||||
def increment_signup_invite_use(invite_id: int) -> None:
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
|
||||
@@ -1919,6 +1919,20 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
message = _normalize_optional_text(payload.get("message"))
|
||||
reason = _normalize_optional_text(payload.get("reason"))
|
||||
|
||||
if template_key == 'invited':
|
||||
if not invite:
|
||||
raise HTTPException(status_code=400, detail='Choose an invitation before sending it.')
|
||||
if int(invite.get('use_count') or 0) > 0:
|
||||
raise HTTPException(status_code=400, detail='This invitation has already been used. Create a new invitation.')
|
||||
if invite.get('recipient_email') and normalize_delivery_email(invite['recipient_email']) != recipient_email:
|
||||
raise HTTPException(status_code=400, detail='This invitation belongs to a different recipient. Create a new invitation.')
|
||||
invite = update_signup_invite(
|
||||
int(invite['id']), code=invite['code'], label=invite.get('label'),
|
||||
description=invite.get('description'), profile_id=invite.get('profile_id'),
|
||||
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
|
||||
expires_at=invite.get('expires_at'), recipient_email=recipient_email,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await send_templated_email(
|
||||
template_key,
|
||||
|
||||
@@ -28,7 +28,8 @@ from ..db import (
|
||||
create_signup_invite,
|
||||
update_signup_invite,
|
||||
delete_signup_invite,
|
||||
increment_signup_invite_use,
|
||||
reserve_signup_invite_use,
|
||||
release_signup_invite_use,
|
||||
get_user_profile,
|
||||
get_user_activity,
|
||||
get_user_activity_summary,
|
||||
@@ -398,6 +399,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) -
|
||||
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
||||
return {
|
||||
"code": invite.get("code"),
|
||||
"email_bound": bool(invite.get("recipient_email")),
|
||||
"label": invite.get("label"),
|
||||
"description": invite.get("description"),
|
||||
"enabled": bool(invite.get("enabled")),
|
||||
@@ -920,6 +922,16 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
if remaining_uses is not None and int(remaining_uses) <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
|
||||
|
||||
account_email = normalize_delivery_email(invite.get('recipient_email'))
|
||||
if account_email:
|
||||
supplied_email = str(payload.get('email') or '').strip()
|
||||
if supplied_email and normalize_delivery_email(supplied_email) != account_email:
|
||||
raise HTTPException(status_code=400, detail='This invitation is tied to the email address it was sent to.')
|
||||
else:
|
||||
account_email = normalize_delivery_email(payload.get('email'))
|
||||
if not account_email:
|
||||
raise HTTPException(status_code=400, detail='A valid email address is required to create your account.')
|
||||
|
||||
profile = None
|
||||
profile_id = invite.get("profile_id")
|
||||
if profile_id is not None:
|
||||
@@ -946,6 +958,10 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
if isinstance(account_expires_days, int) and account_expires_days > 0:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
|
||||
|
||||
if not reserve_signup_invite_use(int(invite['id'])):
|
||||
raise HTTPException(status_code=403, detail='This invitation has already been used or is unavailable.')
|
||||
account_created = False
|
||||
try:
|
||||
runtime = get_runtime_settings()
|
||||
auth_provider = "local"
|
||||
local_password_value = password_value
|
||||
@@ -999,7 +1015,7 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
username,
|
||||
local_password_value,
|
||||
role=role,
|
||||
email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
|
||||
email=account_email,
|
||||
auth_provider=auth_provider,
|
||||
jellyseerr_user_id=matched_jellyseerr_user_id,
|
||||
auto_search_enabled=auto_search_enabled,
|
||||
@@ -1010,7 +1026,7 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
increment_signup_invite_use(int(invite["id"]))
|
||||
account_created = True
|
||||
created_user = get_user_by_username(username)
|
||||
if auth_provider == "jellyfin":
|
||||
sync_jellyfin_password_state(username, password_value)
|
||||
@@ -1053,6 +1069,9 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
"expires_at": created_user.get("expires_at") if created_user else None,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if not account_created:
|
||||
release_signup_invite_use(int(invite['id']))
|
||||
|
||||
|
||||
@router.post("/password/forgot")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException, Response
|
||||
from backend.app import db
|
||||
from backend.app.routers import auth
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class InviteEmailSignupTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
async def signup(self, code, username, **extra):
|
||||
with patch.object(auth, 'get_runtime_settings', return_value=SimpleNamespace(jellyfin_base_url=None, jellyfin_api_key=None)), patch.object(auth, 'send_templated_email', new_callable=AsyncMock), patch.object(auth, 'create_access_token', return_value='test-token'):
|
||||
return await auth.signup({'invite_code': code, 'username': username, 'password': 'Strong-Test-Password123!', **extra}, Response())
|
||||
|
||||
async def test_email_invite_binds_account_and_cannot_be_reused(self):
|
||||
invite = db.create_signup_invite(code='EMAILTEST', recipient_email='recipient@example.com', max_uses=20)
|
||||
self.assertEqual(invite['max_uses'], 1)
|
||||
public = auth._public_invite_payload(invite)
|
||||
self.assertTrue(public['email_bound'])
|
||||
self.assertNotIn('recipient@example.com', str(public))
|
||||
await self.signup('EMAILTEST', 'first-user')
|
||||
self.assertEqual(db.get_user_by_username('first-user')['email'], 'recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('EMAILTEST', 'second-user')
|
||||
|
||||
async def test_email_invite_rejects_recipient_override(self):
|
||||
db.create_signup_invite(code='BOUNDTEST', recipient_email='recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('BOUNDTEST', 'override-user', email='different@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('BOUNDTEST')['use_count'], 0)
|
||||
|
||||
async def test_manual_invite_requires_and_saves_email(self):
|
||||
db.create_signup_invite(code='MANUALTEST', max_uses=3)
|
||||
for email in ['', 'invalid']:
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('MANUALTEST', 'manual-user', email=email)
|
||||
await self.signup('MANUALTEST', 'manual-user', email='manual@example.com')
|
||||
self.assertEqual(db.get_user_by_username('manual-user')['email'], 'manual@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('MANUALTEST')['remaining_uses'], 2)
|
||||
|
||||
async def test_failed_creation_releases_reservation(self):
|
||||
invite = db.create_signup_invite(code='FAILTEST', recipient_email='recipient@example.com')
|
||||
with patch.object(auth, 'create_user', side_effect=RuntimeError('test failure')):
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('FAILTEST', 'failed-user')
|
||||
self.assertEqual(db.get_signup_invite_by_id(invite['id'])['use_count'], 0)
|
||||
|
||||
async def test_single_use_reservation_is_atomic(self):
|
||||
invite = db.create_signup_invite(code='RACETEST', recipient_email='recipient@example.com')
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(db.reserve_signup_invite_use, [invite['id']] * 4))
|
||||
self.assertEqual(sum(results), 1)
|
||||
@@ -0,0 +1,68 @@
|
||||
# Historical production cutover notes — superseded
|
||||
|
||||
These notes describe the temporary AMS-DEV01 setup, not the current production
|
||||
deployment. Do not run these cutover or rollback instructions against the live
|
||||
service. See [current production instructions](../../PRODUCTION.md).
|
||||
|
||||
Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and
|
||||
`docker-compose.production.yml`. The legacy `prod` deployment and beta are not
|
||||
overwritten. Main runs CI verification; production activation is deliberately
|
||||
manual during the initial cutover.
|
||||
|
||||
Only API connection URLs/credentials and SMTP configuration are exported by
|
||||
`scripts/prepare_production_settings.py`. It reads the source's effective settings,
|
||||
uses an explicit allowlist, refuses existing output directories, and creates
|
||||
private files. It never copies a database, users, invite codes, issues, history,
|
||||
tokens, sessions, branding or notification templates. A new bootstrap admin and
|
||||
JWT secret are generated. Retrieve the bootstrap credentials from the protected
|
||||
`bootstrap-admin.json` on the server; never commit them.
|
||||
|
||||
The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables
|
||||
`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic
|
||||
imports and repair emails. The cover is not an authentication/security boundary;
|
||||
normal API authentication remains in force. Administrators can use `/login`.
|
||||
|
||||
Run `docker compose -f docker-compose.production.yml up -d --build` from the
|
||||
production directory. Caddy should proxy this hostname to `10.30.1.32:3200`;
|
||||
Next forwards `/api` internally. The backend health port is localhost-only at
|
||||
8200. Do not alter beta's route or other Caddy sites.
|
||||
|
||||
Before public activation, validate Caddy config, save its existing configuration,
|
||||
verify HTTPS, admin login, connection diagnostics and the empty-client-data state.
|
||||
Do not send SMTP tests without approval. Keep the old upstream for rollback.
|
||||
|
||||
At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`,
|
||||
then recreate the container. External service records can then be imported through
|
||||
normal synchronization; no beta client data is migrated. Review quality profiles,
|
||||
root folders, invite policy and notification rules in admin settings before use.
|
||||
|
||||
## Initial cutover — 7 September 2026
|
||||
|
||||
- Public HTTPS cover and `/api/health` verified after cutover.
|
||||
- Caddy: AMS-CAD01, `/etc/caddy/Caddyfile`, systemd `caddy.service`.
|
||||
- SSH worked via `10.30.40.254` using `HostKeyAlias=10.30.41.254`.
|
||||
- Only the `magent.grizzlyflix.co.nz` upstream changed, from
|
||||
`10.30.1.81:3002` to `10.30.1.32:3200`. Both beta blocks were unchanged.
|
||||
- Rollback configuration: `/etc/caddy/Caddyfile.bak-magent-prod-20260907T0130`.
|
||||
Restore it, run `sudo caddy validate --config /etc/caddy/Caddyfile`, then
|
||||
`sudo systemctl reload caddy`. Review subsequent edits before restoring the
|
||||
whole file; the old application was not stopped or deleted.
|
||||
- Initial database: one newly generated bootstrap admin; zero invites, issues,
|
||||
cached requests, actions or snapshots. Login smoke-testing subsequently creates
|
||||
normal admin login activity only.
|
||||
- Retrieve `/home/zak/magent-production/bootstrap-admin.json` securely on
|
||||
AMS-DEV01. Sign in at `/login`, then open `/admin` while the cover is active.
|
||||
- No SMTP message was sent as part of validation. Background jobs remain paused.
|
||||
|
||||
## Cover resilience update
|
||||
|
||||
The application host subsequently became unreachable over TCP from Caddy (both
|
||||
3100 and 3200 timed out, despite responding to ping). The cover is now served
|
||||
directly by Caddy from `/var/lib/caddy/magent-cover/index.html`, sourced from
|
||||
`docker/coming-soon.html`, for `/`, `/coming-soon` and `/coming-soon/`.
|
||||
It needs no application server, JavaScript, API or external assets.
|
||||
Other paths retain the production reverse proxy. Full launch now also requires
|
||||
removing the `@landing`/static `handle` block from the production Caddy site once
|
||||
upstream connectivity is stable; the environment switch alone is insufficient.
|
||||
Pre-static configuration backup:
|
||||
`/etc/caddy/Caddyfile.bak-magent-static-20260907T0145`.
|
||||
@@ -1,33 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY app ./app
|
||||
COPY public ./public
|
||||
COPY next-env.d.ts ./next-env.d.ts
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/next.config.js ./next.config.js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -3631,13 +3631,51 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.issue-portal-page .portal-item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
max-height: 100%;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.issue-portal-page .portal-item-row {
|
||||
padding: 11px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: min-content;
|
||||
max-height: none;
|
||||
padding: 14px;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.issue-portal-page .portal-item-row-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.issue-portal-page .portal-item-row-title strong {
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.issue-portal-page .portal-item-row-meta {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--ops-line-soft);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.issue-portal-page .portal-item-row-meta > span:last-child {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.issue-portal-page .portal-item-row p {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { clearToken, getApiBase, setToken } from '../lib/auth'
|
||||
|
||||
type InviteInfo = {
|
||||
code: string
|
||||
email_bound?: boolean
|
||||
label?: string | null
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
@@ -38,14 +39,15 @@ function SignupPageContent() {
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return Boolean(invite?.is_usable && username.trim() && password && !loading)
|
||||
}, [invite, username, password, loading])
|
||||
return Boolean(invite?.is_usable && (invite.email_bound || email.trim()) && username.trim() && password && !loading && !inviteLoading)
|
||||
}, [invite, email, username, password, loading, inviteLoading])
|
||||
|
||||
const lookupInvite = async (code: string) => {
|
||||
const trimmed = code.trim()
|
||||
@@ -110,6 +112,7 @@ function SignupPageContent() {
|
||||
body: JSON.stringify({
|
||||
invite_code: inviteCode,
|
||||
username: username.trim(),
|
||||
...(!invite.email_bound ? { email: email.trim() } : {}),
|
||||
password,
|
||||
}),
|
||||
})
|
||||
@@ -120,7 +123,7 @@ function SignupPageContent() {
|
||||
const data = await response.json()
|
||||
if (data?.authenticated) {
|
||||
setToken('cookie')
|
||||
window.location.href = '/'
|
||||
window.location.href = '/welcome'
|
||||
return
|
||||
}
|
||||
throw new Error('Sign-up did not complete')
|
||||
@@ -140,7 +143,7 @@ function SignupPageContent() {
|
||||
<div className="invite-lookup-row">
|
||||
<input
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
onChange={(e) => { setInviteCode(e.target.value); setInvite(null); setEmail('') }}
|
||||
placeholder="Paste your invite code"
|
||||
autoCapitalize="characters"
|
||||
/>
|
||||
@@ -171,6 +174,10 @@ function SignupPageContent() {
|
||||
</div></details>
|
||||
</div>
|
||||
)}
|
||||
{invite?.email_bound ? <p className="account-hint">Your account will use the email address this invitation was sent to. This invitation can be used once.</p> : <label>
|
||||
Email address
|
||||
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" placeholder="you@example.com" />
|
||||
</label>}
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function InviteDeliveryChoice({ value, onChange }: {
|
||||
<span className="delivery-choice-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
||||
{method === 'manual' ? <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2" /></> : <><rect x="3" y="5" width="18" height="14" rx="3" /><path d="m3 7 9 6 9-6" /></>}
|
||||
</svg></span>
|
||||
<span className="delivery-choice-copy"><strong>{method === 'manual' ? 'Copy a link' : 'Send an email'}</strong><small>{method === 'manual' ? 'Share it yourself. No email needed.' : 'We’ll send the invite. You get the link too.'}</small></span>
|
||||
<span className="delivery-choice-copy"><strong>{method === 'manual' ? 'Copy a link' : 'Send an email'}</strong><small>{method === 'manual' ? 'They add their email when signing up.' : 'One use, tied to the recipient’s email. You get the link too.'}</small></span>
|
||||
<span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'magent_theme'
|
||||
|
||||
const getPreferredTheme = () => {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
return stored
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
const applyTheme = (theme: string) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
|
||||
|
||||
useEffect(() => {
|
||||
const preferred = getPreferredTheme()
|
||||
setTheme(preferred)
|
||||
applyTheme(preferred)
|
||||
}, [])
|
||||
|
||||
const toggle = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(next)
|
||||
applyTheme(next)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY, next)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="theme-toggle"
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3a8.5 8.5 0 1 0 11.5 11.5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path "$PSScriptRoot\\.."
|
||||
Set-Location $repoRoot
|
||||
|
||||
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "scripts/run_backend_quality_gate.ps1 failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
$now = Get-Date
|
||||
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
|
||||
|
||||
Write-Host "Build number: $buildNumber"
|
||||
|
||||
git tag $buildNumber
|
||||
git push origin $buildNumber
|
||||
|
||||
$backendImage = "rephl3xnz/magent-backend:$buildNumber"
|
||||
$frontendImage = "rephl3xnz/magent-frontend:$buildNumber"
|
||||
|
||||
docker build -f backend/Dockerfile -t $backendImage --build-arg BUILD_NUMBER=$buildNumber .
|
||||
docker build -f frontend/Dockerfile -t $frontendImage frontend
|
||||
|
||||
docker tag $backendImage rephl3xnz/magent-backend:latest
|
||||
docker tag $frontendImage rephl3xnz/magent-frontend:latest
|
||||
|
||||
docker push $backendImage
|
||||
docker push $frontendImage
|
||||
docker push rephl3xnz/magent-backend:latest
|
||||
docker push rephl3xnz/magent-frontend:latest
|
||||
@@ -0,0 +1,10 @@
|
||||
function Set-EnvBuildNumber {
|
||||
param(
|
||||
[AllowEmptyString()][string]$Content,
|
||||
[Parameter(Mandatory = $true)][string]$BuildNumber
|
||||
)
|
||||
if ($BuildNumber -notmatch '^\d+$') { throw 'Build number must contain digits only.' }
|
||||
$newline = if ($Content.Contains("`r`n")) { "`r`n" } else { "`n" }
|
||||
$remaining = [regex]::Replace($Content, '(?m)^[\t ]*(?:export[\t ]+)?BUILD_NUMBER[\t ]*=[^\r\n]*(?:\r?\n|$)', '')
|
||||
return "BUILD_NUMBER=$BuildNumber$newline$remaining"
|
||||
}
|
||||
+3
-10
@@ -5,6 +5,7 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. (Join-Path $PSScriptRoot 'env_build_number.ps1')
|
||||
|
||||
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
||||
Set-Location $repoRoot
|
||||
@@ -172,16 +173,8 @@ function Update-BuildFiles {
|
||||
$envPath = Join-Path $repoRoot ".env"
|
||||
if (Test-Path $envPath) {
|
||||
$envContent = Read-TextFile -Path ".env"
|
||||
if ($envContent -match '^BUILD_NUMBER=.*$') {
|
||||
$updatedEnv = [regex]::Replace(
|
||||
$envContent,
|
||||
'^BUILD_NUMBER=.*$',
|
||||
"BUILD_NUMBER=$BuildNumber",
|
||||
[System.Text.RegularExpressions.RegexOptions]::Multiline
|
||||
)
|
||||
} else {
|
||||
$updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
|
||||
}
|
||||
# Remove previous assignments before adding exactly one build number.
|
||||
$updatedEnv = Set-EnvBuildNumber -Content $envContent -BuildNumber $BuildNumber
|
||||
Write-TextFile -Path ".env" -Content $updatedEnv
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT);
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const css = ['globals.css', 'ops-redesign.css', 'admin/config.css', 'account.css', 'workspace.css', 'portal/issue-flow.css'].map(p => fs.readFileSync(`frontend/app/${p}`, 'utf8')).join('\n');
|
||||
for (const width of [320, 390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.setContent(`<style>${css}</style><main class="issue-portal-page" style="display:block;width:100%;margin:0"><section class="portal-list-panel" style="width: min(100%, 360px);height:600px"><div class="portal-item-list">${Array.from({length: 8}, (_, i) => `<button class="portal-item-row"><div class="portal-item-row-main"><div class="portal-item-row-title"><strong>Issue ${i}: A long movie title with a missing episode and more details</strong><span class="small-pill">Broken media</span><span class="small-pill">Normal</span></div><p>Two lines of issue description to make sure this card expands correctly and does not overlap its neighbours.</p><div class="issue-card-progress">Reported — Step 1 of 6</div><div class="portal-item-row-meta"><span>#${i}</span><span>By: long-test-account@example.invalid</span><span>Updated: 9/7/2026, 10:00:00 PM</span></div></div></button>`).join('')}</div></section></main>`);
|
||||
const bounds = await page.locator('.portal-item-row').evaluateAll(rows => rows.map(row => {
|
||||
const r = row.getBoundingClientRect(), content = row.firstElementChild.getBoundingClientRect();
|
||||
return {top:r.top,bottom:r.bottom,innerTop:content.top,innerBottom:content.bottom};
|
||||
}));
|
||||
bounds.forEach((r,i) => { assert(r.innerTop >= r.top); assert(r.innerBottom <= r.bottom); if(i) assert(r.top >= bounds[i-1].bottom + 7); });
|
||||
console.log(`Issue card content and spacing passed at ${width}px`);
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'env_build_number.ps1')
|
||||
|
||||
foreach ($newline in @("`n", "`r`n")) {
|
||||
foreach ($content in @('', 'BUILD_NUMBER=1', "# keep$newline`BUILD_NUMBER=1$newline`TOKEN=example=unchanged$newline`BUILD_NUMBER=2$newline", "export BUILD_NUMBER=3$newline`OTHER=value$newline")) {
|
||||
$result = Set-EnvBuildNumber -Content $content -BuildNumber '0803262237'
|
||||
if ([regex]::Matches($result, '(?m)^BUILD_NUMBER=').Count -ne 1) { throw 'Duplicate build numbers remain' }
|
||||
if ((Set-EnvBuildNumber -Content $result -BuildNumber '0803262237') -cne $result) { throw 'Update is not idempotent' }
|
||||
$expected = [regex]::Replace($content, '(?m)^(?:export )?BUILD_NUMBER=[^\r\n]*(?:\r?\n|$)', '')
|
||||
$actual = [regex]::Replace($result, '(?m)^BUILD_NUMBER=[^\r\n]*(?:\r?\n|$)', '')
|
||||
if ($actual -cne $expected) { throw 'Unrelated settings changed' }
|
||||
}
|
||||
}
|
||||
Write-Host 'Build-number tests passed: empty, existing, duplicates, LF/CRLF, preservation and repeat updates.'
|
||||
Reference in New Issue
Block a user