feat: add backup recovery, setup wizard and user-view guards
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user