Enforce recipient-bound single-use invites and fix issue card layout; clean release tooling
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.venv/
|
||||||
|
**/.pytest_cache/
|
||||||
|
stitch_magent_media_operations_redesign/
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
bootstrap-admin.json
|
bootstrap-admin.json
|
||||||
release.tar
|
release.tar
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -11,3 +11,10 @@ backend/.pytest_cache/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/.next/
|
frontend/.next/
|
||||||
*.log
|
*.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
|
Magent runs as one combined frontend/API image: `rephl3xnz/magent`.
|
||||||
`docker-compose.production.yml`. The legacy `prod` deployment and beta are not
|
The root `Dockerfile` is the supported build entry point. Source releases come
|
||||||
overwritten. Main runs CI verification; production activation is deliberately
|
from `main`; use `prod-<short-commit>` tags to identify an exact release.
|
||||||
manual during the initial cutover.
|
|
||||||
|
|
||||||
Only API connection URLs/credentials and SMTP configuration are exported by
|
## Live deployment
|
||||||
`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
|
- Host: GRZ-DKR01 (`10.30.1.81`).
|
||||||
`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic
|
- Container and Compose service: `magent`; Compose project: `arrstack`.
|
||||||
imports and repair emails. The cover is not an authentication/security boundary;
|
- Compose file: `/home/zak/grizzlystack/arrstack/docker-compose.yml`.
|
||||||
normal API authentication remains in force. Administrators can use `/login`.
|
- 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
|
## Release checklist
|
||||||
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,
|
1. Run the backend tests and frontend production build. Review only the intended
|
||||||
verify HTTPS, admin login, connection diagnostics and the empty-client-data state.
|
changes, then commit and push `main`.
|
||||||
Do not send SMTP tests without approval. Keep the old upstream for rollback.
|
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`,
|
For rollback, select the saved image and recreate only Magent. Restore data only
|
||||||
then recreate the container. External service records can then be imported through
|
if needed; doing so can discard activity since the backup. Never restore a whole
|
||||||
normal synchronization; no beta client data is migrated. Review quality profiles,
|
shared Compose or Caddy file without checking for unrelated changes first.
|
||||||
root folders, invite policy and notification rules in admin settings before use.
|
|
||||||
|
|
||||||
## Initial cutover — 7 September 2026
|
## Build metadata
|
||||||
|
|
||||||
- Public HTTPS cover and `/api/health` verified after cutover.
|
`.build_number` and `backend/app/build_info.py` currently hold the same legacy
|
||||||
- Caddy: AMS-CAD01, `/etc/caddy/Caddyfile`, systemd `caddy.service`.
|
display build number as the frontend package files. `.env` should have exactly
|
||||||
- SSH worked via `10.30.40.254` using `HostKeyAlias=10.30.41.254`.
|
one `BUILD_NUMBER` assignment, not a history of previous releases. Docker release
|
||||||
- Only the `magent.grizzlyflix.co.nz` upstream changed, from
|
tags identify the deployed source commit independently of this display value.
|
||||||
`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
|
`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
|
## Fresh instances and historical notes
|
||||||
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
|
`scripts/prepare_production_settings.py` exports only allowlisted connection and
|
||||||
`docker/coming-soon.html`, for `/`, `/coming-soon` and `/coming-soon/`.
|
SMTP settings for a fresh instance. Do not use it to replace a live database.
|
||||||
It needs no application server, JavaScript, API or external assets.
|
`docker-compose.production.yml` is the separate fresh-instance template, not the
|
||||||
Other paths retain the production reverse proxy. Full launch now also requires
|
live GRZ-DKR01 Compose file. `docker-compose.hub.yml` is the generic Docker Hub
|
||||||
removing the `@landing`/static `handle` block from the production Caddy site once
|
template; `docker-compose.yml` builds locally; `docker-compose.beta.yml` serves beta.
|
||||||
upstream connectivity is stable; the environment switch alone is insufficient.
|
|
||||||
Pre-static configuration backup:
|
The temporary AMS-DEV01 setup and coming-soon cutover are retained under
|
||||||
`/etc/caddy/Caddyfile.bak-magent-static-20260907T0145`.
|
[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]:
|
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)
|
use_count = int(row[7] or 0)
|
||||||
expires_at = row[9]
|
expires_at = row[9]
|
||||||
is_expired = _is_datetime_in_past(expires_at)
|
is_expired = _is_datetime_in_past(expires_at)
|
||||||
@@ -1657,6 +1657,8 @@ def create_signup_invite(
|
|||||||
recipient_email: Optional[str] = None,
|
recipient_email: Optional[str] = None,
|
||||||
created_by: Optional[str] = None,
|
created_by: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
if recipient_email:
|
||||||
|
max_uses = 1
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
@@ -1714,6 +1716,11 @@ def update_signup_invite(
|
|||||||
expires_at: Optional[str],
|
expires_at: Optional[str],
|
||||||
recipient_email: Optional[str],
|
recipient_email: Optional[str],
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> 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()
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
@@ -1751,6 +1758,24 @@ def delete_signup_invite(invite_id: int) -> bool:
|
|||||||
return cursor.rowcount > 0
|
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:
|
def increment_signup_invite_use(invite_id: int) -> None:
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
with _connect() as conn:
|
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"))
|
message = _normalize_optional_text(payload.get("message"))
|
||||||
reason = _normalize_optional_text(payload.get("reason"))
|
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:
|
try:
|
||||||
result = await send_templated_email(
|
result = await send_templated_email(
|
||||||
template_key,
|
template_key,
|
||||||
|
|||||||
+114
-95
@@ -28,7 +28,8 @@ from ..db import (
|
|||||||
create_signup_invite,
|
create_signup_invite,
|
||||||
update_signup_invite,
|
update_signup_invite,
|
||||||
delete_signup_invite,
|
delete_signup_invite,
|
||||||
increment_signup_invite_use,
|
reserve_signup_invite_use,
|
||||||
|
release_signup_invite_use,
|
||||||
get_user_profile,
|
get_user_profile,
|
||||||
get_user_activity,
|
get_user_activity,
|
||||||
get_user_activity_summary,
|
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:
|
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
||||||
return {
|
return {
|
||||||
"code": invite.get("code"),
|
"code": invite.get("code"),
|
||||||
|
"email_bound": bool(invite.get("recipient_email")),
|
||||||
"label": invite.get("label"),
|
"label": invite.get("label"),
|
||||||
"description": invite.get("description"),
|
"description": invite.get("description"),
|
||||||
"enabled": bool(invite.get("enabled")),
|
"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:
|
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")
|
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 = None
|
||||||
profile_id = invite.get("profile_id")
|
profile_id = invite.get("profile_id")
|
||||||
if profile_id is not None:
|
if profile_id is not None:
|
||||||
@@ -946,113 +958,120 @@ async def signup(payload: dict, response: Response) -> dict:
|
|||||||
if isinstance(account_expires_days, int) and account_expires_days > 0:
|
if isinstance(account_expires_days, int) and account_expires_days > 0:
|
||||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
|
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
|
||||||
|
|
||||||
runtime = get_runtime_settings()
|
if not reserve_signup_invite_use(int(invite['id'])):
|
||||||
auth_provider = "local"
|
raise HTTPException(status_code=403, detail='This invitation has already been used or is unavailable.')
|
||||||
local_password_value = password_value
|
account_created = False
|
||||||
matched_jellyseerr_user_id: int | None = None
|
try:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
auth_provider = "local"
|
||||||
if jellyfin_client.configured():
|
|
||||||
logger.info("signup provisioning jellyfin username=%s", username)
|
|
||||||
auth_provider = "jellyfin"
|
|
||||||
local_password_value = password_value
|
local_password_value = password_value
|
||||||
try:
|
matched_jellyseerr_user_id: int | None = None
|
||||||
await jellyfin_client.create_user_with_password(username, password_value)
|
|
||||||
except httpx.HTTPStatusError as exc:
|
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
status_code = exc.response.status_code if exc.response is not None else None
|
if jellyfin_client.configured():
|
||||||
duplicate_like = status_code in {400, 409}
|
logger.info("signup provisioning jellyfin username=%s", username)
|
||||||
if duplicate_like:
|
auth_provider = "jellyfin"
|
||||||
try:
|
local_password_value = password_value
|
||||||
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
|
try:
|
||||||
except Exception as auth_exc:
|
await jellyfin_client.create_user_with_password(username, password_value)
|
||||||
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status_code = exc.response.status_code if exc.response is not None else None
|
||||||
|
duplicate_like = status_code in {400, 409}
|
||||||
|
if duplicate_like:
|
||||||
|
try:
|
||||||
|
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
|
||||||
|
except Exception as auth_exc:
|
||||||
|
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Jellyfin account already exists for that username.",
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
detail = _extract_http_error_detail(exc)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
|
detail=f"Jellyfin account provisioning failed: {detail}",
|
||||||
) from exc
|
) from exc
|
||||||
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
except Exception as exc:
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Jellyfin account already exists for that username.",
|
|
||||||
) from exc
|
|
||||||
else:
|
|
||||||
detail = _extract_http_error_detail(exc)
|
detail = _extract_http_error_detail(exc)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"Jellyfin account provisioning failed: {detail}",
|
detail=f"Jellyfin account provisioning failed: {detail}",
|
||||||
) from exc
|
) from exc
|
||||||
except Exception as exc:
|
|
||||||
detail = _extract_http_error_detail(exc)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail=f"Jellyfin account provisioning failed: {detail}",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
await _refresh_jellyfin_user_cache(jellyfin_client)
|
await _refresh_jellyfin_user_cache(jellyfin_client)
|
||||||
jellyseerr_users = get_cached_jellyseerr_users()
|
jellyseerr_users = get_cached_jellyseerr_users()
|
||||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
||||||
if candidate_map:
|
if candidate_map:
|
||||||
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
|
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
|
||||||
|
|
||||||
try:
|
|
||||||
create_user(
|
|
||||||
username,
|
|
||||||
local_password_value,
|
|
||||||
role=role,
|
|
||||||
email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
|
|
||||||
auth_provider=auth_provider,
|
|
||||||
jellyseerr_user_id=matched_jellyseerr_user_id,
|
|
||||||
auto_search_enabled=auto_search_enabled,
|
|
||||||
profile_id=int(profile_id) if profile_id is not None else None,
|
|
||||||
expires_at=expires_at,
|
|
||||||
invited_by_code=invite.get("code"),
|
|
||||||
)
|
|
||||||
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"]))
|
|
||||||
created_user = get_user_by_username(username)
|
|
||||||
if auth_provider == "jellyfin":
|
|
||||||
sync_jellyfin_password_state(username, password_value)
|
|
||||||
if (
|
|
||||||
created_user
|
|
||||||
and created_user.get("jellyseerr_user_id") is None
|
|
||||||
and matched_jellyseerr_user_id is not None
|
|
||||||
):
|
|
||||||
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
|
|
||||||
created_user = get_user_by_username(username)
|
|
||||||
if created_user:
|
|
||||||
try:
|
try:
|
||||||
await send_templated_email(
|
create_user(
|
||||||
"welcome",
|
username,
|
||||||
invite=invite,
|
local_password_value,
|
||||||
user=created_user,
|
role=role,
|
||||||
|
email=account_email,
|
||||||
|
auth_provider=auth_provider,
|
||||||
|
jellyseerr_user_id=matched_jellyseerr_user_id,
|
||||||
|
auto_search_enabled=auto_search_enabled,
|
||||||
|
profile_id=int(profile_id) if profile_id is not None else None,
|
||||||
|
expires_at=expires_at,
|
||||||
|
invited_by_code=invite.get("code"),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Welcome email delivery is best-effort and must not break signup.
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
logger.warning("Welcome email send skipped for %s: %s", username, exc)
|
|
||||||
_assert_user_can_login(created_user)
|
account_created = True
|
||||||
token = create_access_token(username, role)
|
created_user = get_user_by_username(username)
|
||||||
set_last_login(username)
|
if auth_provider == "jellyfin":
|
||||||
logger.info(
|
sync_jellyfin_password_state(username, password_value)
|
||||||
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
|
if (
|
||||||
username,
|
created_user
|
||||||
role,
|
and created_user.get("jellyseerr_user_id") is None
|
||||||
created_user.get("auth_provider") if created_user else auth_provider,
|
and matched_jellyseerr_user_id is not None
|
||||||
created_user.get("profile_id") if created_user else None,
|
):
|
||||||
invite.get("code"),
|
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
|
||||||
)
|
created_user = get_user_by_username(username)
|
||||||
return _auth_success_response(
|
if created_user:
|
||||||
response,
|
try:
|
||||||
token,
|
await send_templated_email(
|
||||||
{
|
"welcome",
|
||||||
"username": username,
|
invite=invite,
|
||||||
"role": role,
|
user=created_user,
|
||||||
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
|
)
|
||||||
"profile_id": created_user.get("profile_id") if created_user else None,
|
except Exception as exc:
|
||||||
"expires_at": created_user.get("expires_at") if created_user else None,
|
# Welcome email delivery is best-effort and must not break signup.
|
||||||
},
|
logger.warning("Welcome email send skipped for %s: %s", username, exc)
|
||||||
)
|
_assert_user_can_login(created_user)
|
||||||
|
token = create_access_token(username, role)
|
||||||
|
set_last_login(username)
|
||||||
|
logger.info(
|
||||||
|
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
|
||||||
|
username,
|
||||||
|
role,
|
||||||
|
created_user.get("auth_provider") if created_user else auth_provider,
|
||||||
|
created_user.get("profile_id") if created_user else None,
|
||||||
|
invite.get("code"),
|
||||||
|
)
|
||||||
|
return _auth_success_response(
|
||||||
|
response,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"username": username,
|
||||||
|
"role": role,
|
||||||
|
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
|
||||||
|
"profile_id": created_user.get("profile_id") if created_user else None,
|
||||||
|
"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")
|
@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 {
|
.issue-portal-page .portal-item-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issue-portal-page .portal-item-row {
|
.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 {
|
.issue-portal-page .portal-item-row p {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { clearToken, getApiBase, setToken } from '../lib/auth'
|
|||||||
|
|
||||||
type InviteInfo = {
|
type InviteInfo = {
|
||||||
code: string
|
code: string
|
||||||
|
email_bound?: boolean
|
||||||
label?: string | null
|
label?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -38,14 +39,15 @@ function SignupPageContent() {
|
|||||||
const [inviteLoading, setInviteLoading] = useState(false)
|
const [inviteLoading, setInviteLoading] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
return Boolean(invite?.is_usable && username.trim() && password && !loading)
|
return Boolean(invite?.is_usable && (invite.email_bound || email.trim()) && username.trim() && password && !loading && !inviteLoading)
|
||||||
}, [invite, username, password, loading])
|
}, [invite, email, username, password, loading, inviteLoading])
|
||||||
|
|
||||||
const lookupInvite = async (code: string) => {
|
const lookupInvite = async (code: string) => {
|
||||||
const trimmed = code.trim()
|
const trimmed = code.trim()
|
||||||
@@ -110,6 +112,7 @@ function SignupPageContent() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
invite_code: inviteCode,
|
invite_code: inviteCode,
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
|
...(!invite.email_bound ? { email: email.trim() } : {}),
|
||||||
password,
|
password,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -120,7 +123,7 @@ function SignupPageContent() {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data?.authenticated) {
|
if (data?.authenticated) {
|
||||||
setToken('cookie')
|
setToken('cookie')
|
||||||
window.location.href = '/'
|
window.location.href = '/welcome'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error('Sign-up did not complete')
|
throw new Error('Sign-up did not complete')
|
||||||
@@ -140,7 +143,7 @@ function SignupPageContent() {
|
|||||||
<div className="invite-lookup-row">
|
<div className="invite-lookup-row">
|
||||||
<input
|
<input
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChange={(e) => setInviteCode(e.target.value)}
|
onChange={(e) => { setInviteCode(e.target.value); setInvite(null); setEmail('') }}
|
||||||
placeholder="Paste your invite code"
|
placeholder="Paste your invite code"
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
/>
|
/>
|
||||||
@@ -171,6 +174,10 @@ function SignupPageContent() {
|
|||||||
</div></details>
|
</div></details>
|
||||||
</div>
|
</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>
|
<label>
|
||||||
Username
|
Username
|
||||||
<input
|
<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">
|
<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" /></>}
|
{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>
|
</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>
|
<span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span>
|
||||||
</button>)}
|
</button>)}
|
||||||
</div>
|
</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"
|
$ErrorActionPreference = "Stop"
|
||||||
|
. (Join-Path $PSScriptRoot 'env_build_number.ps1')
|
||||||
|
|
||||||
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
||||||
Set-Location $repoRoot
|
Set-Location $repoRoot
|
||||||
@@ -172,16 +173,8 @@ function Update-BuildFiles {
|
|||||||
$envPath = Join-Path $repoRoot ".env"
|
$envPath = Join-Path $repoRoot ".env"
|
||||||
if (Test-Path $envPath) {
|
if (Test-Path $envPath) {
|
||||||
$envContent = Read-TextFile -Path ".env"
|
$envContent = Read-TextFile -Path ".env"
|
||||||
if ($envContent -match '^BUILD_NUMBER=.*$') {
|
# Remove previous assignments before adding exactly one build number.
|
||||||
$updatedEnv = [regex]::Replace(
|
$updatedEnv = Set-EnvBuildNumber -Content $envContent -BuildNumber $BuildNumber
|
||||||
$envContent,
|
|
||||||
'^BUILD_NUMBER=.*$',
|
|
||||||
"BUILD_NUMBER=$BuildNumber",
|
|
||||||
[System.Text.RegularExpressions.RegexOptions]::Multiline
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
$updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
|
|
||||||
}
|
|
||||||
Write-TextFile -Path ".env" -Content $updatedEnv
|
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