feat: add backup recovery, setup wizard and user-view guards
Magent CI/CD / verify (push) Successful in 5m20s
Magent CI/CD / deploy-beta (push) Successful in 1m41s

This commit is contained in:
2026-09-18 17:23:03 +12:00
parent a6a4a9aa24
commit fd6671cf7e
44 changed files with 4650 additions and 114 deletions
+46 -26
View File
@@ -1,12 +1,12 @@
"use client";
import ResolutionChoice from "../ui/ResolutionChoice";
import PageHeading from "../ui/PageHeading";
import IssueFlowStep from "./IssueFlowStep";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
import { useEffectiveRole } from "../lib/viewMode";
import PageHeading from "../ui/PageHeading";
import ResolutionChoice from "../ui/ResolutionChoice";
import IssueFlowStep from "./IssueFlowStep";
type PortalPermissions = {
can_edit?: boolean;
@@ -536,7 +536,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([]);
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([]);
const isAdmin = me?.role === "admin";
const effectiveRole = useEffectiveRole(me?.role);
const isAdmin = effectiveRole === "admin";
const isOwner = (item: PortalItem) => me?.username === item.created_by_username;
const canConfirmResolution = (item: PortalItem) =>
Boolean(item.permissions?.can_confirm_resolution && (isAdmin || isOwner(item)));
const canEditSelected = Boolean(selectedItem?.permissions?.can_edit && (isAdmin || isOwner(selectedItem)));
const canModerateSelected = Boolean(isAdmin && selectedItem?.permissions?.can_moderate);
const canDeleteSelected = Boolean(isAdmin && selectedItem?.permissions?.can_delete);
const visibleComments = comments.filter((comment) => isAdmin || !comment.is_internal);
const visibleActivity = activity.filter((entry) => isAdmin || entry.event_type !== "internal_note_added");
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0);
const workspaceLabel = workspace === "request" ? "request" : "issue";
const workspaceLabelPlural = workspace === "request" ? "requests" : "issues";
@@ -610,6 +619,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
});
const afterTargets: IssueStep = issueNeedsDevices ? "devices" : "review";
useEffect(() => {
if (isAdmin) return;
setDeleteConfirming(false);
if (commentInternal) {
// Do not turn an unfinished internal note into a public comment when preview changes.
setCommentText("");
setCommentInternal(false);
}
}, [isAdmin, commentInternal]);
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
@@ -1335,7 +1354,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const saveItem = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedItem) return;
if (!selectedItem || !canEditSelected) return;
setSaving(true);
setError(null);
setStatus(null);
@@ -1347,7 +1366,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
year: editYear.trim() ? toPositiveInt(editYear) : null,
external_ref: editExternalRef || null,
};
if (selectedItem.permissions?.can_moderate) {
if (canModerateSelected) {
if (selectedItem.kind === "request") {
payload.request_status = editRequestStatus;
payload.media_status = editMediaStatus;
@@ -1390,6 +1409,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const postComment = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedItem) return;
if (commentInternal && !isAdmin) return;
if (!commentText.trim()) {
setError("Comment message is required.");
return;
@@ -1404,7 +1424,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: commentText,
is_internal: commentInternal,
is_internal: isAdmin && commentInternal,
}),
});
if (!response.ok) {
@@ -1429,7 +1449,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
};
const respondToResolution = async (resolved: boolean) => {
if (!selectedItem) return;
if (!selectedItem || !canConfirmResolution(selectedItem)) return;
setRespondingResolution(true);
setError(null);
setStatus(null);
@@ -1465,7 +1485,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
};
const deleteIssue = async () => {
if (selectedItem?.kind !== "issue" || !selectedItem.permissions?.can_delete) return;
if (selectedItem?.kind !== "issue" || !canDeleteSelected) return;
setDeleting(true);
setError(null);
setStatus(null);
@@ -1550,7 +1570,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
.filter(
(item) =>
item.status === "awaiting_confirmation" &&
item.permissions?.can_confirm_resolution &&
canConfirmResolution(item) &&
item.created_by_username === me?.username,
)
.map((item) => (
@@ -2416,7 +2436,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</strong>
</div>
<div className="issue-modal-toolbar-actions">
{selectedItem?.permissions?.can_delete ? (
{canDeleteSelected ? (
<button
type="button"
className="danger-button"
@@ -2442,7 +2462,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<>
{selectedItem.kind === "issue" &&
selectedItem.status === "awaiting_confirmation" &&
selectedItem.permissions?.can_confirm_resolution && (
canConfirmResolution(selectedItem) && (
<ResolutionChoice
title={selectedItem.title}
busy={respondingResolution}
@@ -2478,7 +2498,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</div>
{selectedItem.kind === "issue" && deleteConfirming ? (
{selectedItem.kind === "issue" && canDeleteSelected && deleteConfirming ? (
<section className="issue-delete-confirmation" aria-live="polite">
<div>
<span className="section-kicker">Permanent deletion</span>
@@ -2529,7 +2549,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
/>
</label>
<label className="portal-field-span-2">
@@ -2538,7 +2558,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
rows={4}
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
/>
</label>
{selectedItem.kind === "request" ? (
@@ -2548,7 +2568,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<select
value={editMediaType}
onChange={(event) => setEditMediaType(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
>
{MEDIA_TYPE_OPTIONS.map((option) => (
<option key={option.value || "none"} value={option.value}>
@@ -2563,7 +2583,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
value={editYear}
onChange={(event) => setEditYear(event.target.value)}
inputMode="numeric"
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
/>
</label>
</>
@@ -2573,10 +2593,10 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<input
value={editExternalRef}
onChange={(event) => setEditExternalRef(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
disabled={!canEditSelected}
/>
</label>
{selectedItem.permissions?.can_moderate && (
{canModerateSelected && (
<>
{selectedItem.kind === "request" ? (
<>
@@ -2652,7 +2672,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</>
)}
<div className="admin-inline-actions portal-field-span-2">
<button type="submit" disabled={saving || !selectedItem.permissions?.can_edit}>
<button type="submit" disabled={saving || !canEditSelected}>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
@@ -2665,13 +2685,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<span className="section-kicker">Recorded work</span>
<h3>Issue activity</h3>
</div>
<span className="small-pill">{activity.length} events</span>
<span className="small-pill">{visibleActivity.length} events</span>
</div>
{activity.length === 0 ? (
{visibleActivity.length === 0 ? (
<div className="status-banner">No issue activity has been recorded yet.</div>
) : (
<ol className="issue-activity-list">
{activity.map((entry) => (
{visibleActivity.map((entry) => (
<li key={entry.id}>
<i aria-hidden="true" />
<div>
@@ -2690,11 +2710,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<div className="portal-comments-block">
<h3>Comments</h3>
{comments.length === 0 ? (
{visibleComments.length === 0 ? (
<div className="status-banner">No comments yet.</div>
) : (
<div className="portal-comment-list">
{comments.map((comment) => (
{visibleComments.map((comment) => (
<article key={comment.id} className="portal-comment-card">
<header>
<strong>{comment.author_username}</strong>