feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.page p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.summary,
|
||||
.pending,
|
||||
.panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.summary p,
|
||||
.panel > p,
|
||||
.muted,
|
||||
.help {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pending {
|
||||
border-color: var(--accent);
|
||||
border-inline-start-width: 4px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-bottom: 16px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input[type="file"] {
|
||||
padding: 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.page button {
|
||||
justify-self: start;
|
||||
min-height: 44px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.page input:focus-visible,
|
||||
.page button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.summary,
|
||||
.pending,
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.page button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiUrl, requestJson } from "../../lib/api-client";
|
||||
import { authFetchOrThrow, ForbiddenError, UnauthorizedError } from "../../lib/auth";
|
||||
import AdminShell from "../../ui/AdminShell";
|
||||
import styles from "./backups.module.css";
|
||||
|
||||
type BackupDetails = {
|
||||
created_at: string;
|
||||
build: string;
|
||||
include_cache: boolean;
|
||||
};
|
||||
|
||||
type BackupStatus = {
|
||||
format_version: number;
|
||||
max_upload_bytes: number;
|
||||
max_expanded_bytes: number;
|
||||
include_cache_default: boolean;
|
||||
pending_restore: (BackupDetails & { staged_at: string }) | null;
|
||||
last_restore: { restored_at: string; rollback_directory: string; status?: string; message?: string } | null;
|
||||
};
|
||||
|
||||
type RestoreResult = {
|
||||
status: "staged";
|
||||
restart_required: true;
|
||||
backup: BackupDetails;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const dateLabel = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
};
|
||||
|
||||
const sizeLabel = (bytes: number) => `${Math.ceil(bytes / (1024 * 1024))} MiB`;
|
||||
|
||||
export default function BackupsPage() {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<BackupStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busy, setBusy] = useState<"export" | "restore" | "cancel" | null>(null);
|
||||
const [includeCache, setIncludeCache] = useState(false);
|
||||
const [exportPassphrase, setExportPassphrase] = useState("");
|
||||
const [confirmPassphrase, setConfirmPassphrase] = useState("");
|
||||
const [restorePassphrase, setRestorePassphrase] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleError = useCallback(
|
||||
(cause: unknown, fallback: string) => {
|
||||
if (cause instanceof UnauthorizedError) {
|
||||
router.replace("/login?next=%2Fadmin%2Fbackups");
|
||||
return;
|
||||
}
|
||||
if (cause instanceof ForbiddenError) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
setError(cause instanceof Error ? cause.message : fallback);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void revision;
|
||||
const abort = new AbortController();
|
||||
setLoading(true);
|
||||
void requestJson<BackupStatus>("/admin/backups", { signal: abort.signal })
|
||||
.then((result) => {
|
||||
if (abort.signal.aborted) return;
|
||||
setData(result);
|
||||
setIncludeCache(result.include_cache_default);
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
if (!abort.signal.aborted) handleError(cause, "Could not load backup settings.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!abort.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [revision, handleError]);
|
||||
|
||||
const exportBackup = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
setNotice("");
|
||||
if (exportPassphrase.length < 12 || exportPassphrase.length > 1024) {
|
||||
setError("Choose a backup passphrase between 12 and 1,024 characters.");
|
||||
return;
|
||||
}
|
||||
if (exportPassphrase !== confirmPassphrase) {
|
||||
setError("The backup passphrases do not match.");
|
||||
return;
|
||||
}
|
||||
setBusy("export");
|
||||
try {
|
||||
const response = await authFetchOrThrow(apiUrl("/admin/backups/export"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ passphrase: exportPassphrase, include_cache: includeCache }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload ? payload.detail : null;
|
||||
throw new Error(typeof detail === "string" ? detail : "Could not create the backup. Please try again.");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const filename = response.headers.get("Content-Disposition")?.match(/filename="?([\w.-]+\.magent-backup)"?/);
|
||||
link.href = downloadUrl;
|
||||
link.download = filename?.[1] ?? `magent-${new Date().toISOString().slice(0, 10)}.magent-backup`;
|
||||
link.hidden = true;
|
||||
document.body.appendChild(link);
|
||||
try {
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
|
||||
}
|
||||
setExportPassphrase("");
|
||||
setConfirmPassphrase("");
|
||||
setNotice("Your encrypted backup is ready. Check your downloads and store its passphrase somewhere safe.");
|
||||
} catch (cause) {
|
||||
handleError(cause, "Could not create the backup.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreBackup = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (busy || !data || data.pending_restore) return;
|
||||
setError("");
|
||||
setNotice("");
|
||||
if (!file || file.size === 0) {
|
||||
setError("Choose a Magent backup file to restore.");
|
||||
return;
|
||||
}
|
||||
if (file.size > data.max_upload_bytes) {
|
||||
setError(`The backup must be no larger than ${sizeLabel(data.max_upload_bytes)}.`);
|
||||
return;
|
||||
}
|
||||
if (restorePassphrase.length < 12 || restorePassphrase.length > 1024) {
|
||||
setError("Enter the backup passphrase, between 12 and 1,024 characters.");
|
||||
return;
|
||||
}
|
||||
if (confirmation !== "RESTORE") {
|
||||
setError("Type RESTORE to confirm that this backup will replace the current Magent data.");
|
||||
return;
|
||||
}
|
||||
setBusy("restore");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("passphrase", restorePassphrase);
|
||||
form.append("confirmation", confirmation);
|
||||
const result = await requestJson<RestoreResult>("/admin/backups/restore", { method: "POST", body: form });
|
||||
setData((current) =>
|
||||
current ? { ...current, pending_restore: { ...result.backup, staged_at: new Date().toISOString() } } : current,
|
||||
);
|
||||
setRestorePassphrase("");
|
||||
setConfirmation("");
|
||||
setFile(null);
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
setNotice(
|
||||
"Backup checked and ready to restore. Restart Magent to apply it, or cancel the pending restore below.",
|
||||
);
|
||||
} catch (cause) {
|
||||
handleError(cause, "Could not prepare the restore.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRestore = async () => {
|
||||
if (busy) return;
|
||||
setBusy("cancel");
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await requestJson("/admin/backups/restore", { method: "DELETE" });
|
||||
setData((current) => (current ? { ...current, pending_restore: null } : current));
|
||||
setNotice("Pending restore cancelled. Your current data is unchanged.");
|
||||
} catch (cause) {
|
||||
handleError(cause, "Could not cancel the restore.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell title="Backup & restore" subtitle="Save a secure copy of your Magent settings, database, and cache.">
|
||||
<div className={styles.page}>
|
||||
{error && (
|
||||
<p className="error-banner" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className={styles.notice} role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{loading && <p role="status">Loading backup settings...</p>}
|
||||
{!loading && !data && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setRevision((current) => current + 1);
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<section className={styles.summary} aria-labelledby="backup-contents">
|
||||
<h2 id="backup-contents">What is saved</h2>
|
||||
<p>
|
||||
Every backup includes Magent settings, app connection credentials, branding, and the complete database
|
||||
with accounts, invites, requests, and cached records. You can also include downloaded artwork caches.
|
||||
</p>
|
||||
<p>
|
||||
Connected apps and media files need their own backups. App credentials configured through the
|
||||
environment are included, but the deployment environment file, host paths, and signing or encryption
|
||||
keys are not.
|
||||
</p>
|
||||
{data.last_restore && (
|
||||
<p className={styles.muted}>
|
||||
{data.last_restore.status === "rolled_back"
|
||||
? "Last restore was rolled back"
|
||||
: "Last restore completed"}
|
||||
: {dateLabel(data.last_restore.restored_at)}.
|
||||
{data.last_restore.status === "rolled_back" &&
|
||||
` ${data.last_restore.message || "The previous data was recovered automatically."}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{data.pending_restore && (
|
||||
<section className={styles.pending} aria-labelledby="pending-restore-title">
|
||||
<h2 id="pending-restore-title">Restore ready — restart required</h2>
|
||||
<p>
|
||||
Backup from {dateLabel(data.pending_restore.created_at)}
|
||||
{data.pending_restore.build ? ` (build ${data.pending_restore.build})` : ""}. Artwork cache{" "}
|
||||
{data.pending_restore.include_cache ? "included" : "not included"}.
|
||||
</p>
|
||||
<p>
|
||||
Restart the Magent container or service to apply this backup. Changes made since the backup was
|
||||
created will be replaced. Afterwards, sign in again with an administrator account from the restored
|
||||
backup.
|
||||
</p>
|
||||
<button type="button" className="ghost-button" onClick={cancelRestore} disabled={!!busy}>
|
||||
{busy === "cancel" ? "Cancelling..." : "Cancel pending restore"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className={styles.columns}>
|
||||
<section className={styles.panel} aria-labelledby="create-backup-title">
|
||||
<h2 id="create-backup-title">Create a backup</h2>
|
||||
<p>Download an encrypted backup file. Keep the file and its passphrase in a safe place.</p>
|
||||
<p>
|
||||
Backups must fit within {sizeLabel(data.max_upload_bytes)} encrypted and{" "}
|
||||
{sizeLabel(data.max_expanded_bytes)} when expanded. If artwork makes your backup too large, leave
|
||||
artwork caches unchecked.
|
||||
</p>
|
||||
<form onSubmit={exportBackup} aria-busy={busy === "export"}>
|
||||
<fieldset className={styles.fields} disabled={!!busy}>
|
||||
<legend className={styles.legend}>Backup options</legend>
|
||||
<label className={styles.checkbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeCache}
|
||||
onChange={(event) => setIncludeCache(event.target.checked)}
|
||||
aria-describedby="cache-help"
|
||||
/>
|
||||
Include artwork caches
|
||||
</label>
|
||||
<p id="cache-help" className={styles.help}>
|
||||
Adds downloaded images to the backup. This makes the file larger; images can otherwise be fetched
|
||||
again. Database caches are always included.
|
||||
</p>
|
||||
<label className={styles.field}>
|
||||
Backup passphrase
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={exportPassphrase}
|
||||
onChange={(event) => setExportPassphrase(event.target.value)}
|
||||
minLength={12}
|
||||
maxLength={1024}
|
||||
required
|
||||
aria-describedby="backup-passphrase-help"
|
||||
/>
|
||||
</label>
|
||||
<p id="backup-passphrase-help" className={styles.help}>
|
||||
Use at least 12 characters. This passphrase is separate from your login password. A lost
|
||||
passphrase cannot be recovered.
|
||||
</p>
|
||||
<label className={styles.field}>
|
||||
Confirm backup passphrase
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassphrase}
|
||||
onChange={(event) => setConfirmPassphrase(event.target.value)}
|
||||
minLength={12}
|
||||
maxLength={1024}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">
|
||||
{busy === "export" ? "Preparing backup..." : "Download encrypted backup"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className={styles.panel} aria-labelledby="restore-backup-title">
|
||||
<h2 id="restore-backup-title">Restore a backup</h2>
|
||||
<p>
|
||||
Restoring replaces Magent's settings and database, including users and invites. Download a
|
||||
current backup first if you want to keep these changes.
|
||||
</p>
|
||||
<form onSubmit={restoreBackup} aria-busy={busy === "restore"}>
|
||||
<fieldset className={styles.fields} disabled={!!busy || !!data.pending_restore}>
|
||||
<legend className={styles.legend}>Choose and confirm a backup</legend>
|
||||
<label className={styles.field}>
|
||||
Backup file
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".magent-backup,application/octet-stream"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
required
|
||||
aria-describedby="backup-file-help"
|
||||
/>
|
||||
</label>
|
||||
<p id="backup-file-help" className={styles.help}>
|
||||
Choose a .magent-backup file, up to {sizeLabel(data.max_upload_bytes)}.
|
||||
</p>
|
||||
<label className={styles.field}>
|
||||
Backup passphrase
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={restorePassphrase}
|
||||
onChange={(event) => setRestorePassphrase(event.target.value)}
|
||||
minLength={12}
|
||||
maxLength={1024}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Type RESTORE to confirm replacement
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
pattern="RESTORE"
|
||||
required
|
||||
aria-describedby="restore-restart-help"
|
||||
/>
|
||||
</label>
|
||||
<p id="restore-restart-help" className={styles.help}>
|
||||
The backup is checked before being queued. It only takes effect when you restart Magent; you can
|
||||
cancel before then. You will need to sign in using an account from the backup.
|
||||
</p>
|
||||
<button type="submit" className="danger-button">
|
||||
{busy === "restore" ? "Checking and uploading..." : "Prepare restore"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user