Build 2602261636: self-service invites and count fixes
This commit is contained in:
@@ -1 +1 @@
|
|||||||
2602261605
|
2602261636
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
BUILD_NUMBER = "2602261605"
|
BUILD_NUMBER = "2602261636"
|
||||||
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
@@ -14,6 +16,11 @@ from ..db import (
|
|||||||
set_jellyfin_auth_cache,
|
set_jellyfin_auth_cache,
|
||||||
set_user_jellyseerr_id,
|
set_user_jellyseerr_id,
|
||||||
get_signup_invite_by_code,
|
get_signup_invite_by_code,
|
||||||
|
get_signup_invite_by_id,
|
||||||
|
list_signup_invites,
|
||||||
|
create_signup_invite,
|
||||||
|
update_signup_invite,
|
||||||
|
delete_signup_invite,
|
||||||
increment_signup_invite_use,
|
increment_signup_invite_use,
|
||||||
get_user_profile,
|
get_user_profile,
|
||||||
get_user_activity,
|
get_user_activity,
|
||||||
@@ -156,6 +163,118 @@ def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_positive_int(value: object, field_name: str) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a number") from exc
|
||||||
|
if parsed <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"{field_name} must be greater than 0",
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_expires_at(value: object) -> str | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="expires_at must be an ISO datetime string",
|
||||||
|
)
|
||||||
|
candidate = value.strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="expires_at must be a valid ISO datetime",
|
||||||
|
) from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_invite_code(value: str | None) -> str:
|
||||||
|
raw = (value or "").strip().upper()
|
||||||
|
filtered = "".join(ch for ch in raw if ch.isalnum())
|
||||||
|
if len(filtered) < 6:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invite code must be at least 6 letters/numbers.",
|
||||||
|
)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_invite_code(length: int = 12) -> str:
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def _same_username(a: object, b: object) -> bool:
|
||||||
|
if not isinstance(a, str) or not isinstance(b, str):
|
||||||
|
return False
|
||||||
|
return a.strip().lower() == b.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_self_invite(invite: dict) -> dict:
|
||||||
|
profile = None
|
||||||
|
profile_id = invite.get("profile_id")
|
||||||
|
if profile_id is not None:
|
||||||
|
try:
|
||||||
|
profile = get_user_profile(int(profile_id))
|
||||||
|
except Exception:
|
||||||
|
profile = None
|
||||||
|
return {
|
||||||
|
"id": invite.get("id"),
|
||||||
|
"code": invite.get("code"),
|
||||||
|
"label": invite.get("label"),
|
||||||
|
"description": invite.get("description"),
|
||||||
|
"profile_id": invite.get("profile_id"),
|
||||||
|
"profile": (
|
||||||
|
{"id": profile.get("id"), "name": profile.get("name")}
|
||||||
|
if isinstance(profile, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"role": invite.get("role"),
|
||||||
|
"max_uses": invite.get("max_uses"),
|
||||||
|
"use_count": invite.get("use_count", 0),
|
||||||
|
"remaining_uses": invite.get("remaining_uses"),
|
||||||
|
"enabled": bool(invite.get("enabled")),
|
||||||
|
"expires_at": invite.get("expires_at"),
|
||||||
|
"is_expired": bool(invite.get("is_expired")),
|
||||||
|
"is_usable": bool(invite.get("is_usable")),
|
||||||
|
"created_at": invite.get("created_at"),
|
||||||
|
"updated_at": invite.get("updated_at"),
|
||||||
|
"created_by": invite.get("created_by"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_invites(username: str) -> list[dict]:
|
||||||
|
owned = [
|
||||||
|
invite
|
||||||
|
for invite in list_signup_invites()
|
||||||
|
if _same_username(invite.get("created_by"), username)
|
||||||
|
]
|
||||||
|
owned.sort(key=lambda item: (str(item.get("created_at") or ""), int(item.get("id") or 0)), reverse=True)
|
||||||
|
return owned
|
||||||
|
|
||||||
|
|
||||||
|
def _get_owned_invite(invite_id: int, current_user: dict) -> dict:
|
||||||
|
invite = get_signup_invite_by_id(invite_id)
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||||
|
if not _same_username(invite.get("created_by"), current_user.get("username")):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only manage your own invites")
|
||||||
|
return invite
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||||
user = verify_user_password(form_data.username, form_data.password)
|
user = verify_user_password(form_data.username, form_data.password)
|
||||||
@@ -444,6 +563,124 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile/invites")
|
||||||
|
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
username = str(current_user.get("username") or "").strip()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
|
invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)]
|
||||||
|
return {"invites": invites, "count": len(invites)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile/invites")
|
||||||
|
async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
|
username = str(current_user.get("username") or "").strip()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
|
|
||||||
|
requested_code = payload.get("code")
|
||||||
|
if isinstance(requested_code, str) and requested_code.strip():
|
||||||
|
code = _normalize_invite_code(requested_code)
|
||||||
|
existing = get_signup_invite_by_code(code)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
|
||||||
|
else:
|
||||||
|
code = ""
|
||||||
|
for _ in range(20):
|
||||||
|
candidate = _generate_invite_code()
|
||||||
|
if not get_signup_invite_by_code(candidate):
|
||||||
|
code = candidate
|
||||||
|
break
|
||||||
|
if not code:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not generate invite code")
|
||||||
|
|
||||||
|
label = payload.get("label")
|
||||||
|
description = payload.get("description")
|
||||||
|
if label is not None:
|
||||||
|
label = str(label).strip() or None
|
||||||
|
if description is not None:
|
||||||
|
description = str(description).strip() or None
|
||||||
|
|
||||||
|
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||||
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||||
|
enabled = bool(payload.get("enabled", True))
|
||||||
|
|
||||||
|
profile_id = current_user.get("profile_id")
|
||||||
|
if not isinstance(profile_id, int) or profile_id <= 0:
|
||||||
|
profile_id = None
|
||||||
|
|
||||||
|
invite = create_signup_invite(
|
||||||
|
code=code,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
profile_id=profile_id,
|
||||||
|
role="user",
|
||||||
|
max_uses=max_uses,
|
||||||
|
enabled=enabled,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_by=username,
|
||||||
|
)
|
||||||
|
return {"status": "ok", "invite": _serialize_self_invite(invite)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/profile/invites/{invite_id}")
|
||||||
|
async def update_profile_invite(
|
||||||
|
invite_id: int, payload: dict, current_user: dict = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
|
existing = _get_owned_invite(invite_id, current_user)
|
||||||
|
|
||||||
|
requested_code = payload.get("code", existing.get("code"))
|
||||||
|
if isinstance(requested_code, str) and requested_code.strip():
|
||||||
|
code = _normalize_invite_code(requested_code)
|
||||||
|
else:
|
||||||
|
code = str(existing.get("code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
|
||||||
|
duplicate = get_signup_invite_by_code(code)
|
||||||
|
if duplicate and int(duplicate.get("id") or 0) != int(existing.get("id") or 0):
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists")
|
||||||
|
|
||||||
|
label = payload.get("label", existing.get("label"))
|
||||||
|
description = payload.get("description", existing.get("description"))
|
||||||
|
if label is not None:
|
||||||
|
label = str(label).strip() or None
|
||||||
|
if description is not None:
|
||||||
|
description = str(description).strip() or None
|
||||||
|
|
||||||
|
max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses")
|
||||||
|
expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at")))
|
||||||
|
enabled_raw = payload.get("enabled", existing.get("enabled"))
|
||||||
|
enabled = bool(enabled_raw)
|
||||||
|
|
||||||
|
invite = update_signup_invite(
|
||||||
|
invite_id,
|
||||||
|
code=code,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
profile_id=existing.get("profile_id"),
|
||||||
|
role=existing.get("role"),
|
||||||
|
max_uses=max_uses,
|
||||||
|
enabled=enabled,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||||
|
return {"status": "ok", "invite": _serialize_self_invite(invite)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/profile/invites/{invite_id}")
|
||||||
|
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
_get_owned_invite(invite_id, current_user)
|
||||||
|
deleted = delete_signup_invite(invite_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password")
|
@router.post("/password")
|
||||||
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
if current_user.get("auth_provider") != "local":
|
if current_user.get("auth_provider") != "local":
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export default function AdminInviteManagementPage() {
|
|||||||
const [invites, setInvites] = useState<Invite[]>([])
|
const [invites, setInvites] = useState<Invite[]>([])
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([])
|
const [profiles, setProfiles] = useState<Profile[]>([])
|
||||||
const [users, setUsers] = useState<AdminUserLite[]>([])
|
const [users, setUsers] = useState<AdminUserLite[]>([])
|
||||||
|
const [jellyfinUsersCount, setJellyfinUsersCount] = useState<number | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const [inviteSaving, setInviteSaving] = useState(false)
|
const [inviteSaving, setInviteSaving] = useState(false)
|
||||||
@@ -175,6 +176,20 @@ export default function AdminInviteManagementPage() {
|
|||||||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||||
setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
|
setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
|
||||||
setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
|
setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
|
||||||
|
try {
|
||||||
|
const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`)
|
||||||
|
if (jellyfinRes.ok) {
|
||||||
|
const jellyfinData = await jellyfinRes.json()
|
||||||
|
setJellyfinUsersCount(Array.isArray(jellyfinData?.users) ? jellyfinData.users.length : 0)
|
||||||
|
} else if (jellyfinRes.status === 401 || jellyfinRes.status === 403) {
|
||||||
|
if (handleAuthResponse(jellyfinRes)) return
|
||||||
|
} else {
|
||||||
|
setJellyfinUsersCount(null)
|
||||||
|
}
|
||||||
|
} catch (jellyfinErr) {
|
||||||
|
console.warn('Could not load Jellyfin user count for invite overview', jellyfinErr)
|
||||||
|
setJellyfinUsersCount(null)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setError('Could not load invite management data.')
|
setError('Could not load invite management data.')
|
||||||
@@ -635,12 +650,19 @@ export default function AdminInviteManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="invite-admin-summary-row">
|
<div className="invite-admin-summary-row">
|
||||||
<span className="label">Non-admin users</span>
|
<span className="label">Local non-admin accounts</span>
|
||||||
<div className="invite-admin-summary-row__value">
|
<div className="invite-admin-summary-row__value">
|
||||||
<strong>{nonAdminUsers.length}</strong>
|
<strong>{nonAdminUsers.length}</strong>
|
||||||
<span>{profiledUsers} with profile</span>
|
<span>{profiledUsers} with profile</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="invite-admin-summary-row">
|
||||||
|
<span className="label">Jellyfin users</span>
|
||||||
|
<div className="invite-admin-summary-row__value">
|
||||||
|
<strong>{jellyfinUsersCount ?? '—'}</strong>
|
||||||
|
<span>{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="invite-admin-summary-row">
|
<div className="invite-admin-summary-row">
|
||||||
<span className="label">Expiry rules</span>
|
<span className="label">Expiry rules</span>
|
||||||
<div className="invite-admin-summary-row__value">
|
<div className="invite-admin-summary-row__value">
|
||||||
@@ -724,12 +746,13 @@ export default function AdminInviteManagementPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Blanket controls</h2>
|
<h2>Blanket controls</h2>
|
||||||
<p className="lede">
|
<p className="lede">
|
||||||
Apply invite profile defaults or expiry to all non-admin users. Individual users can still be edited from their user page.
|
Apply invite profile defaults or expiry to all local non-admin accounts. Individual users can still be edited from their user page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-meta-row">
|
<div className="admin-meta-row">
|
||||||
<span>Non-admin users: {nonAdminUsers.length}</span>
|
<span>Local non-admin users: {nonAdminUsers.length}</span>
|
||||||
|
<span>Jellyfin users: {jellyfinUsersCount ?? '—'}</span>
|
||||||
<span>Profile assigned: {profiledUsers}</span>
|
<span>Profile assigned: {profiledUsers}</span>
|
||||||
<span>Custom expiry set: {expiringUsers}</span>
|
<span>Custom expiry set: {expiringUsers}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4897,3 +4897,96 @@ textarea {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Profile self-service invite management */
|
||||||
|
.profile-invites-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invites-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invites-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invite-form-card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invite-form-card h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #edf2f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invite-form-lede,
|
||||||
|
.profile-invite-hint {
|
||||||
|
color: #9ea7b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invite-hint code {
|
||||||
|
color: #d8e2ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.profile-invites-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Final header account menu stacking override (must be last) */
|
||||||
|
.page,
|
||||||
|
.header,
|
||||||
|
.header-left,
|
||||||
|
.header-right,
|
||||||
|
.header-nav,
|
||||||
|
.header-actions,
|
||||||
|
.signed-in-menu {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative !important;
|
||||||
|
isolation: isolate;
|
||||||
|
z-index: 20 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav,
|
||||||
|
.header-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions a,
|
||||||
|
.header-actions .header-link {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 4000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-in-menu {
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 4500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-in-dropdown {
|
||||||
|
position: absolute !important;
|
||||||
|
z-index: 5000 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
@@ -47,6 +47,45 @@ type ProfileResponse = {
|
|||||||
activity: ProfileActivity
|
activity: ProfileActivity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OwnedInvite = {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
description?: string | null
|
||||||
|
max_uses?: number | null
|
||||||
|
use_count: number
|
||||||
|
remaining_uses?: number | null
|
||||||
|
enabled: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
is_expired?: boolean
|
||||||
|
is_usable?: boolean
|
||||||
|
created_at?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInvitesResponse = {
|
||||||
|
invites?: OwnedInvite[]
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInviteForm = {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
max_uses: string
|
||||||
|
expires_at: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
||||||
|
code: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
max_uses: '',
|
||||||
|
expires_at: '',
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => {
|
const formatDate = (value?: string | null) => {
|
||||||
if (!value) return 'Never'
|
if (!value) return 'Never'
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -72,8 +111,19 @@ export default function ProfilePage() {
|
|||||||
const [currentPassword, setCurrentPassword] = useState('')
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
|
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
||||||
|
const [inviteError, setInviteError] = useState<string | null>(null)
|
||||||
|
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
||||||
|
const [inviteSaving, setInviteSaving] = useState(false)
|
||||||
|
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||||||
|
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const signupBaseUrl = useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') return '/signup'
|
||||||
|
return `${window.location.origin}/signup`
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -82,13 +132,19 @@ export default function ProfilePage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const response = await authFetch(`${baseUrl}/auth/profile`)
|
const [profileResponse, invitesResponse] = await Promise.all([
|
||||||
if (!response.ok) {
|
authFetch(`${baseUrl}/auth/profile`),
|
||||||
|
authFetch(`${baseUrl}/auth/profile/invites`),
|
||||||
|
])
|
||||||
|
if (!profileResponse.ok || !invitesResponse.ok) {
|
||||||
clearToken()
|
clearToken()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const [data, inviteData] = (await Promise.all([
|
||||||
|
profileResponse.json(),
|
||||||
|
invitesResponse.json(),
|
||||||
|
])) as [ProfileResponse, OwnedInvitesResponse]
|
||||||
const user = data?.user ?? {}
|
const user = data?.user ?? {}
|
||||||
setProfile({
|
setProfile({
|
||||||
username: user?.username ?? 'Unknown',
|
username: user?.username ?? 'Unknown',
|
||||||
@@ -97,6 +153,7 @@ export default function ProfilePage() {
|
|||||||
})
|
})
|
||||||
setStats(data?.stats ?? null)
|
setStats(data?.stats ?? null)
|
||||||
setActivity(data?.activity ?? null)
|
setActivity(data?.activity ?? null)
|
||||||
|
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus('Could not load your profile.')
|
setStatus('Could not load your profile.')
|
||||||
@@ -137,6 +194,128 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetInviteEditor = () => {
|
||||||
|
setInviteEditingId(null)
|
||||||
|
setInviteForm(defaultOwnedInviteForm())
|
||||||
|
}
|
||||||
|
|
||||||
|
const editInvite = (invite: OwnedInvite) => {
|
||||||
|
setInviteEditingId(invite.id)
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
setInviteForm({
|
||||||
|
code: invite.code ?? '',
|
||||||
|
label: invite.label ?? '',
|
||||||
|
description: invite.description ?? '',
|
||||||
|
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
||||||
|
expires_at: invite.expires_at ?? '',
|
||||||
|
enabled: invite.enabled !== false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadInvites = async () => {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Invite refresh failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as OwnedInvitesResponse
|
||||||
|
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveInvite = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setInviteSaving(true)
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(
|
||||||
|
inviteEditingId == null
|
||||||
|
? `${baseUrl}/auth/profile/invites`
|
||||||
|
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
|
||||||
|
{
|
||||||
|
method: inviteEditingId == null ? 'POST' : 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: inviteForm.code || null,
|
||||||
|
label: inviteForm.label || null,
|
||||||
|
description: inviteForm.description || null,
|
||||||
|
max_uses: inviteForm.max_uses || null,
|
||||||
|
expires_at: inviteForm.expires_at || null,
|
||||||
|
enabled: inviteForm.enabled,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite save failed')
|
||||||
|
}
|
||||||
|
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||||
|
resetInviteEditor()
|
||||||
|
await reloadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
|
||||||
|
} finally {
|
||||||
|
setInviteSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteInvite = async (invite: OwnedInvite) => {
|
||||||
|
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite delete failed')
|
||||||
|
}
|
||||||
|
if (inviteEditingId === invite.id) {
|
||||||
|
resetInviteEditor()
|
||||||
|
}
|
||||||
|
setInviteStatus(`Deleted invite ${invite.code}.`)
|
||||||
|
await reloadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||||
|
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setInviteStatus(`Copied invite link for ${invite.code}.`)
|
||||||
|
} else {
|
||||||
|
window.prompt('Copy invite link', url)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
window.prompt('Copy invite link', url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <main className="card">Loading profile...</main>
|
return <main className="card">Loading profile...</main>
|
||||||
}
|
}
|
||||||
@@ -222,6 +401,201 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<section className="profile-section profile-invites-section">
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>My invites</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Create and manage invite links you’ve issued. New invites use your account defaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{inviteError && <div className="error-banner">{inviteError}</div>}
|
||||||
|
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
||||||
|
<div className="profile-invites-layout">
|
||||||
|
<div className="profile-invites-list">
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<div className="status-banner">You haven’t created any invites yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-list">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<div key={invite.id} className="admin-list-item">
|
||||||
|
<div className="admin-list-item-main">
|
||||||
|
<div className="admin-list-item-title-row">
|
||||||
|
<code className="invite-code">{invite.code}</code>
|
||||||
|
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||||
|
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||||
|
</span>
|
||||||
|
<span className="small-pill is-muted">
|
||||||
|
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||||||
|
{invite.description && (
|
||||||
|
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||||
|
{invite.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="admin-meta-row">
|
||||||
|
<span>
|
||||||
|
Uses: {invite.use_count}
|
||||||
|
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||||||
|
</span>
|
||||||
|
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||||
|
<span>Created: {formatDate(invite.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => copyInviteLink(invite)}
|
||||||
|
>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => editInvite(invite)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => deleteInvite(invite)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="profile-invite-form-card">
|
||||||
|
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
||||||
|
<p className="meta profile-invite-form-lede">
|
||||||
|
Share the generated signup link with the person you want to invite.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Identity</span>
|
||||||
|
<small>Optional code and label for easier tracking.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
|
<label>
|
||||||
|
<span>Code (optional)</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.code}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, code: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Leave blank to auto-generate"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.label}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, label: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Family invite"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Description</span>
|
||||||
|
<small>Optional note shown on the signup page.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={inviteForm.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Optional note shown on the signup page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Limits</span>
|
||||||
|
<small>Usage cap and optional expiry date/time.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
|
<label>
|
||||||
|
<span>Max uses</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.max_uses}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
||||||
|
}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="Blank = unlimited"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Invite expiry (ISO datetime)</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.expires_at}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="2026-03-01T12:00:00+00:00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Status</span>
|
||||||
|
<small>Enable or disable this invite before sharing.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||||
|
<label className="inline-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteForm.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
enabled: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Invite is enabled
|
||||||
|
</label>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="submit" disabled={inviteSaving}>
|
||||||
|
{inviteSaving
|
||||||
|
? 'Saving…'
|
||||||
|
: inviteEditingId == null
|
||||||
|
? 'Create invite'
|
||||||
|
: 'Save invite'}
|
||||||
|
</button>
|
||||||
|
{inviteEditingId != null && (
|
||||||
|
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||||
|
Cancel edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="meta profile-invite-hint">
|
||||||
|
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{profile?.auth_provider !== 'local' ? (
|
{profile?.auth_provider !== 'local' ? (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Password changes are only available for local Magent accounts.
|
Password changes are only available for local Magent accounts.
|
||||||
|
|||||||
Reference in New Issue
Block a user