@aglyn/plugins-redirects 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +49 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/redirects-console-page.d.ts +25 -0
- package/src/lib/components/redirects-console-page.js +833 -0
- package/src/lib/components/redirects-console-page.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/model/index.d.ts +22 -0
- package/src/lib/model/index.js +22 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/redirects.d.ts +143 -0
- package/src/lib/model/redirects.js +255 -0
- package/src/lib/model/redirects.js.map +1 -0
- package/src/lib/plugin.d.ts +24 -0
- package/src/lib/plugin.js +55 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server/resolve-redirect.d.ts +39 -0
- package/src/lib/server/resolve-redirect.js +131 -0
- package/src/lib/server/resolve-redirect.js.map +1 -0
- package/src/lib/server.d.ts +23 -0
- package/src/lib/server.js +33 -0
- package/src/lib/server.js.map +1 -0
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
19
|
+
import { checkQuota, pluginDocsHelp } from "@aglyn/aglyn";
|
|
20
|
+
import { isExternalRedirectDestination, isSelfRedirect, matchRedirect, normalizeRedirectDestination, normalizeRedirectSource, REDIRECT_DEFAULT_PRIORITY, validateRedirectRule, REDIRECT_STATUS_CODES } from "../model/index.js";
|
|
21
|
+
import { CardDisplay, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
22
|
+
import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
23
|
+
import QuotaReadoutComponent from "@aglyn/shared-ui-jsx/components/quota-readout.component";
|
|
24
|
+
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
25
|
+
import { Timestamp } from "@aglyn/shared-util-timestamp";
|
|
26
|
+
import { Alert, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
|
|
27
|
+
import { collection, deleteField, doc, getCountFromServer, getDoc, setDoc, updateDoc } from "firebase/firestore";
|
|
28
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
29
|
+
import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
|
|
30
|
+
import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
|
|
31
|
+
import { ceilingedWindow, collectionCeiling, useFirestore, useFirestoreCollection, useFirestoreDoc, useHostResourceApi, useUser, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
|
|
32
|
+
/**
|
|
33
|
+
* How many redirect documents this page reads.
|
|
34
|
+
*
|
|
35
|
+
* A CEILING, not a page size. Everything the page decides is computed over
|
|
36
|
+
* the WHOLE rule set: the duplicate-source check, the chain-loop walk that
|
|
37
|
+
* follows internal destinations through the rules, and the inline tester that
|
|
38
|
+
* runs the enforcement matcher. A server page would give each of them a
|
|
39
|
+
* tenth of the rules to reason about, and the duplicate check would then
|
|
40
|
+
* create exactly the collision it exists to prevent.
|
|
41
|
+
*/ const REDIRECT_CEILING = 200;
|
|
42
|
+
/**
|
|
43
|
+
* Redirect manager (AGL-156): CRUD over `hosts/{hostId}/redirects` with
|
|
44
|
+
* the shared AGL-154 validation (normalized sources, https-or-internal
|
|
45
|
+
* destinations, self-redirect refusal, duplicate-source check). Paid
|
|
46
|
+
* (`redirects` flag + `redirectsPerHost` quota); rules take effect on the
|
|
47
|
+
* site within ~30 seconds (AGL-155 ISR window).
|
|
48
|
+
*
|
|
49
|
+
* The plan gate is the SHELL's, and it runs before this component exists
|
|
50
|
+
* (AGL-2484): the console resolves `redirects` from the org billing doc and
|
|
51
|
+
* renders its own refusal notice in place of the surface, so this card is
|
|
52
|
+
* only ever mounted for an entitled org and states no plan terms of its own.
|
|
53
|
+
* Redirects registers no `upgradeNotice` override because it is a genuine
|
|
54
|
+
* upgrade — free denies it, Starter and above grant it — which makes the
|
|
55
|
+
* shell's plan-derived sentence the accurate one already.
|
|
56
|
+
*
|
|
57
|
+
* `entitled` is still read below, where it is not redundant: it holds back
|
|
58
|
+
* the thirty analytics day-doc reads behind the hit counts, and refuses the
|
|
59
|
+
* add path ahead of the server that enforces it.
|
|
60
|
+
*/ export function RedirectsConsolePage(props) {
|
|
61
|
+
var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
|
|
62
|
+
const { hostId, entitled, org } = props;
|
|
63
|
+
const firestore = useFirestore();
|
|
64
|
+
const createHostResource = useHostResourceApi();
|
|
65
|
+
const { data: currentUser } = useUser();
|
|
66
|
+
const { enqueueSnackbar } = useSnackbar();
|
|
67
|
+
const { confirm } = useConfirmationContext();
|
|
68
|
+
const { data: redirectDocs, status: redirectsStatus, /**
|
|
69
|
+
* The rows this editor is seeded from are unconfirmed by the server
|
|
70
|
+
* (AGL-1358). Editing copies every field the form owns out of the stored
|
|
71
|
+
* rule and writes all of them back, so `merge: true` protects only the
|
|
72
|
+
* fields written elsewhere (`lastHitAt`, `deletedAt`). This is the site
|
|
73
|
+
* where a rollback is hardest to undo: a reverted `destination` on a
|
|
74
|
+
* **301** is cached by every browser that has already followed it, so the
|
|
75
|
+
* stale target outlives the fix in the console. `kind` reverting from
|
|
76
|
+
* exact to prefix silently widens a rule over a whole subtree, and
|
|
77
|
+
* `priority` reorders which rule fires when several match.
|
|
78
|
+
*/ fromCache: redirectsFromCache } = useFirestoreCollection(/*
|
|
79
|
+
* ORDERED AND CEILINGED, with a probe (AGL-2501).
|
|
80
|
+
*
|
|
81
|
+
* `limit(200)` alone is answered in DOCUMENT-ID order and the ids are
|
|
82
|
+
* generated, so the window was a pseudo-random two hundred of the site's
|
|
83
|
+
* rules — sorted by `source` in the browser afterwards, which is what
|
|
84
|
+
* made it invisible: the rules on screen run alphabetically and are
|
|
85
|
+
* simply the wrong rules, and the ones missing leave no gap to notice.
|
|
86
|
+
*
|
|
87
|
+
* `orderBy('source')` is the tempting fix and the dangerous one. It
|
|
88
|
+
* matches only documents that HAVE the field, and nothing validates
|
|
89
|
+
* `source` for presence — `/api/hosts/resources` stores an allow-list of
|
|
90
|
+
* fields and checks none of them — so a rule saved without one would
|
|
91
|
+
* vanish from this page while still redirecting live traffic. The
|
|
92
|
+
* document NAME cannot be absent, so the walk is total: every rule is
|
|
93
|
+
* reachable, and the alphabetical order stays where it belongs, in the
|
|
94
|
+
* sort below over a window the page holds whole.
|
|
95
|
+
*/ ()=>collectionCeiling(collection(firestore, 'hosts', hostId, 'redirects'), REDIRECT_CEILING), [
|
|
96
|
+
firestore,
|
|
97
|
+
hostId
|
|
98
|
+
], {
|
|
99
|
+
idField: '$id'
|
|
100
|
+
});
|
|
101
|
+
const { rows: readRedirects, truncated: redirectsTruncated } = ceilingedWindow(redirectDocs, REDIRECT_CEILING);
|
|
102
|
+
const redirects = [
|
|
103
|
+
...readRedirects
|
|
104
|
+
].filter((redirect)=>!redirect.deletedAt).sort((a, b)=>{
|
|
105
|
+
var _a_source, _b_source;
|
|
106
|
+
return String((_a_source = a.source) != null ? _a_source : '').localeCompare(String((_b_source = b.source) != null ? _b_source : ''));
|
|
107
|
+
});
|
|
108
|
+
/*
|
|
109
|
+
* The page is a SLICE of the ceiling, which is what the three whole-set
|
|
110
|
+
* consumers above require. The count is the rules this page HOLDS, after
|
|
111
|
+
* soft-deleted ones are dropped — not the quota, which is a server count of
|
|
112
|
+
* every document including the soft-deleted, and is read separately below.
|
|
113
|
+
*/ const [page, setPage] = useState(0);
|
|
114
|
+
const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT);
|
|
115
|
+
const visibleRedirects = useMemo(()=>redirects.slice(page * pageSize, page * pageSize + pageSize), // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
116
|
+
[
|
|
117
|
+
redirectDocs,
|
|
118
|
+
page,
|
|
119
|
+
pageSize
|
|
120
|
+
]);
|
|
121
|
+
/**
|
|
122
|
+
* What the ENFORCING route counts, which is not what this page was counting.
|
|
123
|
+
*
|
|
124
|
+
* `hosts/{id}/redirects` is read here as an unordered `limit(200)` window
|
|
125
|
+
* and then filtered to drop soft-deleted rows, and that filtered length was
|
|
126
|
+
* feeding both `checkQuota('redirectsPerHost')` and the readout beside it.
|
|
127
|
+
* The route in `app/api/hosts/resources` counts the collection plainly —
|
|
128
|
+
* `collectionRef.count()`, every document, soft-deleted ones included — so
|
|
129
|
+
* the two disagreed on both counts at once: a rule deleted in this console
|
|
130
|
+
* still occupies a slot on the server, and a site past 200 rules has rows
|
|
131
|
+
* the window never saw.
|
|
132
|
+
*
|
|
133
|
+
* The visible effect is the worst kind: the page shows room, the author
|
|
134
|
+
* fills in the form, and the save is refused by a server counting something
|
|
135
|
+
* else. Same shape as AGL-1716.
|
|
136
|
+
*
|
|
137
|
+
* An aggregation query, so it costs one read unit per thousand documents
|
|
138
|
+
* rather than reading them. Re-run when the document set changes, which
|
|
139
|
+
* covers an add; a soft delete leaves the document in place and so does not
|
|
140
|
+
* move this number — correctly, because it does not move the server's
|
|
141
|
+
* either.
|
|
142
|
+
*
|
|
143
|
+
* Only the COUNT is corrected here. Whether a soft-deleted rule should go on
|
|
144
|
+
* occupying a slot is the quota RULE, and that lives with the enforcing
|
|
145
|
+
* route.
|
|
146
|
+
*/ const [enforcedCount, setEnforcedCount] = useState(null);
|
|
147
|
+
const redirectDocCount = (_ref = redirectDocs == null ? void 0 : redirectDocs.length) != null ? _ref : 0;
|
|
148
|
+
/**
|
|
149
|
+
* `null` when the count cannot be taken, never a number.
|
|
150
|
+
*
|
|
151
|
+
* `try`, not `.catch()`: `getCountFromServer` validates its reference and
|
|
152
|
+
* THROWS synchronously on a bad one, so a rejection handler alone leaves the
|
|
153
|
+
* error to escape the effect and take the page down with it. Unreadable is
|
|
154
|
+
* also not zero — answering zero would tell an author at their cap that the
|
|
155
|
+
* whole allowance is free.
|
|
156
|
+
*/ const countRedirects = useCallback(async ()=>{
|
|
157
|
+
try {
|
|
158
|
+
const snapshot = await getCountFromServer(collection(firestore, 'hosts', hostId, 'redirects'));
|
|
159
|
+
return snapshot.data().count;
|
|
160
|
+
} catch (unused) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}, [
|
|
164
|
+
firestore,
|
|
165
|
+
hostId
|
|
166
|
+
]);
|
|
167
|
+
useEffect(()=>{
|
|
168
|
+
let active = true;
|
|
169
|
+
void countRedirects().then((count)=>{
|
|
170
|
+
if (active && count != null) setEnforcedCount(count);
|
|
171
|
+
});
|
|
172
|
+
return ()=>{
|
|
173
|
+
active = false;
|
|
174
|
+
};
|
|
175
|
+
}, [
|
|
176
|
+
countRedirects,
|
|
177
|
+
redirectDocCount
|
|
178
|
+
]);
|
|
179
|
+
const [draft, setDraft] = useState(null);
|
|
180
|
+
const [testPath, setTestPath] = useState('');
|
|
181
|
+
// Host routing map for the screen-collision warning (AGL-156 spec).
|
|
182
|
+
const { data: host } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId), [
|
|
183
|
+
firestore,
|
|
184
|
+
hostId
|
|
185
|
+
], {
|
|
186
|
+
idField: '$id'
|
|
187
|
+
});
|
|
188
|
+
// Sampled hit counts (AGL-157): summed from the last 30 analytics day
|
|
189
|
+
// docs' `redirects` maps written by enforcement. Counts are sampled —
|
|
190
|
+
// one per ISR revalidation window with traffic, not per request.
|
|
191
|
+
const [hits, setHits] = useState(null);
|
|
192
|
+
useEffect(()=>{
|
|
193
|
+
if (!entitled) return;
|
|
194
|
+
let active = true;
|
|
195
|
+
const ids = Array.from({
|
|
196
|
+
length: 30
|
|
197
|
+
}, (_, index)=>{
|
|
198
|
+
const date = new Date();
|
|
199
|
+
date.setDate(date.getDate() - index);
|
|
200
|
+
return date.toISOString().slice(0, 10);
|
|
201
|
+
});
|
|
202
|
+
void Promise.all(ids.map((id)=>getDoc(doc(firestore, 'hosts', hostId, 'analytics', id)).then((snapshot)=>{
|
|
203
|
+
var _snapshot_get;
|
|
204
|
+
return (_snapshot_get = snapshot.get('redirects')) != null ? _snapshot_get : {};
|
|
205
|
+
}).catch(()=>({})))).then((days)=>{
|
|
206
|
+
if (!active) return;
|
|
207
|
+
const totals = {};
|
|
208
|
+
for (const dayMap of days){
|
|
209
|
+
for (const [redirectId, count] of Object.entries(dayMap)){
|
|
210
|
+
var _totals_redirectId;
|
|
211
|
+
totals[redirectId] = ((_totals_redirectId = totals[redirectId]) != null ? _totals_redirectId : 0) + Number(count);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
setHits(totals);
|
|
215
|
+
});
|
|
216
|
+
return ()=>{
|
|
217
|
+
active = false;
|
|
218
|
+
};
|
|
219
|
+
}, [
|
|
220
|
+
entitled,
|
|
221
|
+
firestore,
|
|
222
|
+
hostId
|
|
223
|
+
]);
|
|
224
|
+
const idKey = (value)=>value.replace(/[.$#[\]/]/g, '_');
|
|
225
|
+
const totalHits = Object.values(hits != null ? hits : {}).reduce((sum, count)=>sum + count, 0);
|
|
226
|
+
const handleAdd = useCallback(()=>{
|
|
227
|
+
if (!entitled) {
|
|
228
|
+
return void enqueueSnackbar('URL redirects require a Starter plan — see Billing to upgrade', {
|
|
229
|
+
variant: 'warning',
|
|
230
|
+
persist: false
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/*
|
|
234
|
+
* The SERVER's count, not the rows on screen — the whole point of this
|
|
235
|
+
* fix. Read from the polled value rather than re-counted on the click:
|
|
236
|
+
* this gate is a courtesy that spares the author a form they are about to
|
|
237
|
+
* be refused, the route is the actual enforcer, and making the button
|
|
238
|
+
* await an aggregation would delay the dialog on every press to sharpen a
|
|
239
|
+
* number the server re-checks anyway.
|
|
240
|
+
*
|
|
241
|
+
* `null` means the count could not be read, and that lets the add through:
|
|
242
|
+
* refusing on a number we do not have would block an author who may well
|
|
243
|
+
* be inside their cap.
|
|
244
|
+
*/ const quota = checkQuota(org, 'redirectsPerHost', enforcedCount != null ? enforcedCount : 0);
|
|
245
|
+
if (!quota.allowed) {
|
|
246
|
+
return void enqueueSnackbar(`Redirect limit reached (${quota.limit}) — upgrade in Billing`, {
|
|
247
|
+
variant: 'warning',
|
|
248
|
+
persist: false
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
setDraft({
|
|
252
|
+
id: null,
|
|
253
|
+
source: '',
|
|
254
|
+
destination: '',
|
|
255
|
+
statusCode: 302,
|
|
256
|
+
kind: 'exact',
|
|
257
|
+
priority: REDIRECT_DEFAULT_PRIORITY,
|
|
258
|
+
// A NEW rule is on: the author is adding it to make it fire, and
|
|
259
|
+
// enforcement queries `where('enabled', '==', true)`, so a rule created
|
|
260
|
+
// without the field would never resolve (AGL-1372).
|
|
261
|
+
enabled: true
|
|
262
|
+
});
|
|
263
|
+
}, [
|
|
264
|
+
entitled,
|
|
265
|
+
org,
|
|
266
|
+
enforcedCount,
|
|
267
|
+
enqueueSnackbar
|
|
268
|
+
]);
|
|
269
|
+
/**
|
|
270
|
+
* Tell the tenant a rule changed (best effort, never awaited into the save
|
|
271
|
+
* outcome). Rule writes are client Firestore writes with no publish step,
|
|
272
|
+
* and the tenant's rules list sits behind the hour-long
|
|
273
|
+
* `tenant-data:{hostId}` backstop — without this call a new rule waits out
|
|
274
|
+
* that TTL while the snackbar above promises ~30 seconds. The route busts
|
|
275
|
+
* the host tag and drops the source path's cached HTML; a regex pattern has
|
|
276
|
+
* no single literal path, so it announces `/` for the tag bust alone and
|
|
277
|
+
* the matching pages catch up on their own ISR window.
|
|
278
|
+
*/ const announceRuleChange = useCallback((source)=>{
|
|
279
|
+
const redirectPath = typeof source === 'string' && source.startsWith('/') ? source : '/';
|
|
280
|
+
void authorizedFetch(currentUser, '/api/screens/revalidate', {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: {
|
|
283
|
+
'Content-Type': 'application/json'
|
|
284
|
+
},
|
|
285
|
+
body: JSON.stringify({
|
|
286
|
+
hostId,
|
|
287
|
+
redirectPath
|
|
288
|
+
})
|
|
289
|
+
}).catch(()=>undefined);
|
|
290
|
+
}, [
|
|
291
|
+
currentUser,
|
|
292
|
+
hostId
|
|
293
|
+
]);
|
|
294
|
+
const handleSave = useCallback(async ()=>{
|
|
295
|
+
var _ref, _ref1;
|
|
296
|
+
if (!draft) return;
|
|
297
|
+
const kind = draft.kind || 'exact';
|
|
298
|
+
// Shared v2 validation (AGL-375): regex patterns must compile; path
|
|
299
|
+
// kinds keep the v1 normalization.
|
|
300
|
+
const problem = validateRedirectRule({
|
|
301
|
+
kind,
|
|
302
|
+
source: draft.source,
|
|
303
|
+
destination: draft.destination
|
|
304
|
+
});
|
|
305
|
+
if (problem) {
|
|
306
|
+
return void enqueueSnackbar(problem, {
|
|
307
|
+
variant: 'warning',
|
|
308
|
+
persist: false
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const source = kind === 'regex' ? draft.source.trim() : normalizeRedirectSource(draft.source);
|
|
312
|
+
const destination = normalizeRedirectDestination(draft.destination);
|
|
313
|
+
if (kind !== 'regex' && isSelfRedirect({
|
|
314
|
+
source,
|
|
315
|
+
destination
|
|
316
|
+
})) {
|
|
317
|
+
return void enqueueSnackbar('That would redirect the path to itself', {
|
|
318
|
+
variant: 'warning',
|
|
319
|
+
persist: false
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const duplicate = redirects.find((redirect)=>{
|
|
323
|
+
var _redirect_kind;
|
|
324
|
+
return redirect.source === source && ((_redirect_kind = redirect.kind) != null ? _redirect_kind : 'exact') === kind && redirect.$id !== draft.id;
|
|
325
|
+
});
|
|
326
|
+
if (duplicate) {
|
|
327
|
+
return void enqueueSnackbar(`A rule for ${source} already exists`, {
|
|
328
|
+
variant: 'warning',
|
|
329
|
+
persist: false
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// Chain-loop detection (AGL-156): follow internal destinations through
|
|
333
|
+
// the rule set; a walk that returns to this source can never execute.
|
|
334
|
+
if (destination.startsWith('/')) {
|
|
335
|
+
const bySource = new Map(redirects.filter((redirect)=>redirect.$id !== draft.id).map((redirect)=>[
|
|
336
|
+
redirect.source,
|
|
337
|
+
redirect.destination
|
|
338
|
+
]));
|
|
339
|
+
let cursor = destination.toLowerCase();
|
|
340
|
+
for(let hop = 0; hop < 10 && cursor; hop += 1){
|
|
341
|
+
if (cursor === source) {
|
|
342
|
+
return void enqueueSnackbar('That destination chains back to this rule — a redirect loop', {
|
|
343
|
+
variant: 'warning',
|
|
344
|
+
persist: false
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const next = bySource.get(cursor);
|
|
348
|
+
cursor = next && next.startsWith('/') ? next.toLowerCase() : undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Screen-route collision: shadowing a live page may be intentional
|
|
352
|
+
// (moved pages) — warn, don't block (decision per the issue).
|
|
353
|
+
const routedPaths = Object.values((_ref = host == null ? void 0 : host.screens) != null ? _ref : {}).map((path)=>path === '/' ? '/' : `/${path}`);
|
|
354
|
+
if (routedPaths.includes(source)) {
|
|
355
|
+
enqueueSnackbar(`${source} is a published page — the redirect takes precedence`, {
|
|
356
|
+
variant: 'info',
|
|
357
|
+
persist: false
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const fields = {
|
|
361
|
+
source,
|
|
362
|
+
destination,
|
|
363
|
+
statusCode: REDIRECT_STATUS_CODES.includes(draft.statusCode) ? draft.statusCode : 302,
|
|
364
|
+
kind,
|
|
365
|
+
priority: Number.isFinite(Number(draft.priority)) ? Number(draft.priority) : REDIRECT_DEFAULT_PRIORITY,
|
|
366
|
+
/**
|
|
367
|
+
* Whatever the rule already was (AGL-1372). This was `true`, so editing
|
|
368
|
+
* a **disabled** rule's target silently re-enabled it — a deliberate
|
|
369
|
+
* "off" (a retired campaign, a rule that broke something) undone by a
|
|
370
|
+
* routine save, changing site routing without the author asking.
|
|
371
|
+
*
|
|
372
|
+
* `!== false` rather than the raw value, because the switch below reads
|
|
373
|
+
* the stored field the same way: absent means on. Enforcement's query is
|
|
374
|
+
* `where('enabled', '==', true)`, so a rule missing the field is not
|
|
375
|
+
* served, and normalising it here makes the console's claim true.
|
|
376
|
+
*
|
|
377
|
+
* The create default lives in `handleAdd` — a new rule seeds the draft
|
|
378
|
+
* `true`, so both defaults hold without either one reading the other.
|
|
379
|
+
*/ enabled: draft.enabled !== false
|
|
380
|
+
};
|
|
381
|
+
/**
|
|
382
|
+
* The publisher's stamp on an external destination (AGL-1881).
|
|
383
|
+
*
|
|
384
|
+
* `matchRedirect` will not send traffic off the platform without it, and
|
|
385
|
+
* `cloud/firebase-firestore.rules` admits no write to this collection from
|
|
386
|
+
* anyone but `canPublishHostContent` — so reaching this line at all is the
|
|
387
|
+
* approval, and saving is how a rule written before that fix starts firing
|
|
388
|
+
* again.
|
|
389
|
+
*
|
|
390
|
+
* Written on EDIT only. A create rides /api/hosts/resources, which stamps
|
|
391
|
+
* the same field from the id token's verified uid; sending it from here
|
|
392
|
+
* would be the client supplying its own provenance, and that route drops
|
|
393
|
+
* it for exactly that reason.
|
|
394
|
+
*
|
|
395
|
+
* Cleared with `deleteField()` when the destination is internal, rather
|
|
396
|
+
* than left standing. `merge: true` never removes a key, so an edit from
|
|
397
|
+
* `https://elsewhere.example` back to `/pricing` would otherwise leave a
|
|
398
|
+
* stamp behind that a LATER edit back to an external target would inherit
|
|
399
|
+
* — approval carried across a destination nobody approved.
|
|
400
|
+
*/ const approval = isExternalRedirectDestination(destination) ? {
|
|
401
|
+
externalDestinationApprovedBy: (_ref1 = currentUser == null ? void 0 : currentUser.uid) != null ? _ref1 : null
|
|
402
|
+
} : {
|
|
403
|
+
externalDestinationApprovedBy: deleteField()
|
|
404
|
+
};
|
|
405
|
+
try {
|
|
406
|
+
if (draft.id) {
|
|
407
|
+
/**
|
|
408
|
+
* Edit stays client-direct (no quota consumed) — and is refused when
|
|
409
|
+
* its seed was never confirmed by the server (AGL-1358); the eight
|
|
410
|
+
* validation refusals above have all already returned by here.
|
|
411
|
+
*
|
|
412
|
+
* `merge: true` is load-bearing, contrary to what this said before
|
|
413
|
+
* (AGL-1372): `fields` is every key the FORM owns, not every key the
|
|
414
|
+
* document holds. `lastHitAt` is written by enforcement
|
|
415
|
+
* (`resolve-redirect`), and `deletedAt` by the delete below — a
|
|
416
|
+
* replacing write would erase both.
|
|
417
|
+
*
|
|
418
|
+
* The guard WRAPS the write. An early return is a shape you can keep
|
|
419
|
+
* while losing the protection; here the write is only reachable
|
|
420
|
+
* through the verdict.
|
|
421
|
+
*/ const verdict = await writeGuardedBySeed({
|
|
422
|
+
subject: 'redirect',
|
|
423
|
+
unreadable: redirectsStatus === 'error',
|
|
424
|
+
fromCache: redirectsFromCache
|
|
425
|
+
}, async ()=>{
|
|
426
|
+
await setDoc(doc(firestore, 'hosts', hostId, 'redirects', draft.id), _extends({}, fields, approval, {
|
|
427
|
+
updatedAt: Timestamp.now()
|
|
428
|
+
}), {
|
|
429
|
+
merge: true
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
// Before `setDraft(null)`, so a refusal keeps the dialog open with
|
|
433
|
+
// what was typed, in the same warning vocabulary as the validation
|
|
434
|
+
// refusals above it.
|
|
435
|
+
if (!verdict.ok) {
|
|
436
|
+
return void enqueueSnackbar(verdict.message, {
|
|
437
|
+
variant: 'warning',
|
|
438
|
+
persist: false
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
// New redirect rides the quota-enforcing resources API (AGL-473) —
|
|
443
|
+
// it also re-checks the `redirects` entitlement server-side.
|
|
444
|
+
await createHostResource({
|
|
445
|
+
hostId,
|
|
446
|
+
resource: 'redirect',
|
|
447
|
+
data: fields
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
// The changed rule, and — when an edit moved the source — the path
|
|
451
|
+
// that just STOPPED redirecting, so its page refreshes too.
|
|
452
|
+
announceRuleChange(source);
|
|
453
|
+
if (draft.id) {
|
|
454
|
+
var _find;
|
|
455
|
+
const previous = (_find = (redirects != null ? redirects : []).find((rule)=>rule.$id === draft.id)) == null ? void 0 : _find.source;
|
|
456
|
+
if (previous && previous !== source) announceRuleChange(previous);
|
|
457
|
+
}
|
|
458
|
+
setDraft(null);
|
|
459
|
+
enqueueSnackbar('Redirect saved — live within ~30 seconds', {
|
|
460
|
+
variant: 'success',
|
|
461
|
+
persist: false
|
|
462
|
+
});
|
|
463
|
+
} catch (error) {
|
|
464
|
+
var _ref2;
|
|
465
|
+
console.error(error);
|
|
466
|
+
enqueueSnackbar((_ref2 = error == null ? void 0 : error.message) != null ? _ref2 : 'An error has occurred', {
|
|
467
|
+
variant: 'error',
|
|
468
|
+
allowDuplicate: true
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}, [
|
|
472
|
+
draft,
|
|
473
|
+
redirects,
|
|
474
|
+
host,
|
|
475
|
+
firestore,
|
|
476
|
+
hostId,
|
|
477
|
+
createHostResource,
|
|
478
|
+
currentUser,
|
|
479
|
+
enqueueSnackbar,
|
|
480
|
+
redirectsStatus,
|
|
481
|
+
redirectsFromCache,
|
|
482
|
+
announceRuleChange
|
|
483
|
+
]);
|
|
484
|
+
/**
|
|
485
|
+
* A refusal has to be visible (AGL-1881).
|
|
486
|
+
*
|
|
487
|
+
* These two writes were bare `await`s. The rules now gate this collection on
|
|
488
|
+
* a publishing role, so an `author` reaching either gets a
|
|
489
|
+
* permission-denied — and an unhandled rejection renders as a control that
|
|
490
|
+
* flips back with no error, which is the worst way to say no. `handleSave`
|
|
491
|
+
* has always ended in a snackbar; these now match it.
|
|
492
|
+
*
|
|
493
|
+
* The card is not role-gated on purpose: it is entitlement-gated, and the
|
|
494
|
+
* rules leave READ to the catch-all so an author can still see the rules
|
|
495
|
+
* that affect the pages they write.
|
|
496
|
+
*/ const reportWriteFailure = useCallback((error)=>{
|
|
497
|
+
var _ref;
|
|
498
|
+
enqueueSnackbar((error == null ? void 0 : error.code) === 'permission-denied' ? 'Changing a redirect needs a publishing role — ask an editor or admin' : (_ref = error == null ? void 0 : error.message) != null ? _ref : 'An error has occurred', {
|
|
499
|
+
variant: 'warning',
|
|
500
|
+
persist: false
|
|
501
|
+
});
|
|
502
|
+
}, [
|
|
503
|
+
enqueueSnackbar
|
|
504
|
+
]);
|
|
505
|
+
const handleToggle = useCallback((redirect)=>async (event)=>{
|
|
506
|
+
try {
|
|
507
|
+
await updateDoc(doc(firestore, 'hosts', hostId, 'redirects', redirect.$id), {
|
|
508
|
+
enabled: event.target.checked,
|
|
509
|
+
updatedAt: Timestamp.now()
|
|
510
|
+
});
|
|
511
|
+
announceRuleChange(redirect.source);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
reportWriteFailure(error);
|
|
514
|
+
}
|
|
515
|
+
}, [
|
|
516
|
+
firestore,
|
|
517
|
+
hostId,
|
|
518
|
+
reportWriteFailure,
|
|
519
|
+
announceRuleChange
|
|
520
|
+
]);
|
|
521
|
+
const handleDelete = useCallback((redirect)=>async ()=>{
|
|
522
|
+
const confirmed = await confirm({
|
|
523
|
+
title: 'Delete this redirect?',
|
|
524
|
+
description: `${redirect.source} stops redirecting.`,
|
|
525
|
+
confirmationText: 'Delete',
|
|
526
|
+
confirmationButtonProps: {
|
|
527
|
+
color: 'error'
|
|
528
|
+
}
|
|
529
|
+
}).then(()=>true).catch(()=>false);
|
|
530
|
+
if (!confirmed) return;
|
|
531
|
+
try {
|
|
532
|
+
await updateDoc(doc(firestore, 'hosts', hostId, 'redirects', redirect.$id), {
|
|
533
|
+
deletedAt: Timestamp.now(),
|
|
534
|
+
enabled: false
|
|
535
|
+
});
|
|
536
|
+
announceRuleChange(redirect.source);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
reportWriteFailure(error);
|
|
539
|
+
}
|
|
540
|
+
}, [
|
|
541
|
+
confirm,
|
|
542
|
+
firestore,
|
|
543
|
+
hostId,
|
|
544
|
+
reportWriteFailure,
|
|
545
|
+
announceRuleChange
|
|
546
|
+
]);
|
|
547
|
+
return /*#__PURE__*/ _jsxs(CardDisplay, {
|
|
548
|
+
header: 'URL redirects',
|
|
549
|
+
help: pluginDocsHelp('redirects', {
|
|
550
|
+
anchor: '#manage-redirects'
|
|
551
|
+
}),
|
|
552
|
+
contentGutterX: true,
|
|
553
|
+
contentGutterY: true,
|
|
554
|
+
children: [
|
|
555
|
+
/*#__PURE__*/ _jsxs(Stack, {
|
|
556
|
+
spacing: 1,
|
|
557
|
+
children: [
|
|
558
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
559
|
+
variant: "body2",
|
|
560
|
+
color: "text.secondary",
|
|
561
|
+
children: 'Exact-path rules; 302 while testing, promote to 301 when ' + 'sure (browsers cache 301 aggressively). Changes go live ' + 'within ~30 seconds.'
|
|
562
|
+
}),
|
|
563
|
+
hits && totalHits > 0 ? /*#__PURE__*/ _jsx(Typography, {
|
|
564
|
+
variant: "caption",
|
|
565
|
+
color: "text.secondary",
|
|
566
|
+
children: `${totalHits} redirect hit${totalHits === 1 ? '' : 's'} in ` + 'the last 30 days (sampled — one per cache window with ' + 'traffic).'
|
|
567
|
+
}) : null,
|
|
568
|
+
visibleRedirects.map((redirect)=>{
|
|
569
|
+
var _redirect_statusCode, _redirect_kind, _hits_idKey;
|
|
570
|
+
return /*#__PURE__*/ _jsxs(Stack, {
|
|
571
|
+
direction: "row",
|
|
572
|
+
spacing: 1,
|
|
573
|
+
sx: {
|
|
574
|
+
alignItems: 'center'
|
|
575
|
+
},
|
|
576
|
+
children: [
|
|
577
|
+
/*#__PURE__*/ _jsx(Switch, {
|
|
578
|
+
size: "small",
|
|
579
|
+
checked: redirect.enabled !== false,
|
|
580
|
+
onChange: handleToggle(redirect)
|
|
581
|
+
}),
|
|
582
|
+
/*#__PURE__*/ _jsx(Chip, {
|
|
583
|
+
size: "small",
|
|
584
|
+
label: (_redirect_statusCode = redirect.statusCode) != null ? _redirect_statusCode : 302
|
|
585
|
+
}),
|
|
586
|
+
((_redirect_kind = redirect.kind) != null ? _redirect_kind : 'exact') !== 'exact' ? /*#__PURE__*/ _jsx(Chip, {
|
|
587
|
+
size: "small",
|
|
588
|
+
variant: "outlined",
|
|
589
|
+
label: redirect.kind
|
|
590
|
+
}) : null,
|
|
591
|
+
isExternalRedirectDestination(redirect.destination) && !redirect.externalDestinationApprovedBy ? /*#__PURE__*/ _jsx(Chip, {
|
|
592
|
+
size: "small",
|
|
593
|
+
color: "warning",
|
|
594
|
+
variant: "outlined",
|
|
595
|
+
label: "not serving",
|
|
596
|
+
title: 'This rule sends visitors to another site. Open it and ' + 'save to confirm the destination — until then it is ' + 'skipped.'
|
|
597
|
+
}) : null,
|
|
598
|
+
/*#__PURE__*/ _jsxs(Stack, {
|
|
599
|
+
sx: {
|
|
600
|
+
flex: 1,
|
|
601
|
+
minWidth: 0
|
|
602
|
+
},
|
|
603
|
+
children: [
|
|
604
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
605
|
+
variant: "body2",
|
|
606
|
+
noWrap: true,
|
|
607
|
+
children: `${redirect.source} → ${redirect.destination}`
|
|
608
|
+
}),
|
|
609
|
+
hits || redirect.lastHitAt ? /*#__PURE__*/ _jsx(Typography, {
|
|
610
|
+
variant: "caption",
|
|
611
|
+
color: "text.secondary",
|
|
612
|
+
children: [
|
|
613
|
+
hits ? `${(_hits_idKey = hits[idKey(redirect.$id)]) != null ? _hits_idKey : 0} hits (30d, sampled)` : null,
|
|
614
|
+
redirect.lastHitAt ? `last ${redirect.lastHitAt.toDate().toLocaleString()}` : null
|
|
615
|
+
].filter(Boolean).join(' · ')
|
|
616
|
+
}) : null
|
|
617
|
+
]
|
|
618
|
+
}),
|
|
619
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
620
|
+
size: "small",
|
|
621
|
+
onClick: ()=>{
|
|
622
|
+
var _redirect_source, _redirect_destination, _redirect_statusCode, _redirect_kind, _redirect_priority;
|
|
623
|
+
return setDraft({
|
|
624
|
+
id: redirect.$id,
|
|
625
|
+
source: (_redirect_source = redirect.source) != null ? _redirect_source : '',
|
|
626
|
+
destination: (_redirect_destination = redirect.destination) != null ? _redirect_destination : '',
|
|
627
|
+
statusCode: (_redirect_statusCode = redirect.statusCode) != null ? _redirect_statusCode : 302,
|
|
628
|
+
kind: (_redirect_kind = redirect.kind) != null ? _redirect_kind : 'exact',
|
|
629
|
+
priority: (_redirect_priority = redirect.priority) != null ? _redirect_priority : REDIRECT_DEFAULT_PRIORITY,
|
|
630
|
+
// Read exactly as the switch beside it does (AGL-1372):
|
|
631
|
+
// an edit must not be a way to turn a rule back on.
|
|
632
|
+
enabled: redirect.enabled !== false
|
|
633
|
+
});
|
|
634
|
+
},
|
|
635
|
+
children: 'Edit'
|
|
636
|
+
}),
|
|
637
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
638
|
+
size: "small",
|
|
639
|
+
color: "error",
|
|
640
|
+
onClick: handleDelete(redirect),
|
|
641
|
+
children: 'Delete'
|
|
642
|
+
})
|
|
643
|
+
]
|
|
644
|
+
}, redirect.$id);
|
|
645
|
+
}),
|
|
646
|
+
redirects.length === 0 ? null : /*#__PURE__*/ _jsx(ListPagination, {
|
|
647
|
+
page: page,
|
|
648
|
+
pageSize: pageSize,
|
|
649
|
+
rowCount: visibleRedirects.length,
|
|
650
|
+
// The rules this page HOLDS, after the soft-deleted ones are
|
|
651
|
+
// dropped. Not the quota figure below it: that is a server
|
|
652
|
+
// count over every document, soft-deleted rows included,
|
|
653
|
+
// because a deleted rule still occupies a slot.
|
|
654
|
+
count: redirects.length,
|
|
655
|
+
onPageChange: setPage,
|
|
656
|
+
onPageSizeChange: setPageSize
|
|
657
|
+
}),
|
|
658
|
+
redirectsTruncated ? /*#__PURE__*/ _jsx(Alert, {
|
|
659
|
+
severity: "info",
|
|
660
|
+
children: `Showing ${REDIRECT_CEILING} rules, ordered by id. This site ` + 'has more — the duplicate check and the tester below only ' + 'cover the ones listed here.'
|
|
661
|
+
}) : null,
|
|
662
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
663
|
+
size: "small",
|
|
664
|
+
color: "primary",
|
|
665
|
+
sx: {
|
|
666
|
+
alignSelf: 'flex-start'
|
|
667
|
+
},
|
|
668
|
+
onClick: handleAdd,
|
|
669
|
+
children: 'Add redirect'
|
|
670
|
+
}),
|
|
671
|
+
/*#__PURE__*/ _jsx(QuotaReadoutComponent, {
|
|
672
|
+
ready: org != null && enforcedCount != null,
|
|
673
|
+
used: enforcedCount != null ? enforcedCount : 0,
|
|
674
|
+
limit: checkQuota(org, 'redirectsPerHost', enforcedCount != null ? enforcedCount : 0).limit,
|
|
675
|
+
noun: "redirect"
|
|
676
|
+
}),
|
|
677
|
+
/*#__PURE__*/ _jsxs(Stack, {
|
|
678
|
+
direction: "row",
|
|
679
|
+
spacing: 1,
|
|
680
|
+
sx: {
|
|
681
|
+
alignItems: 'center'
|
|
682
|
+
},
|
|
683
|
+
children: [
|
|
684
|
+
/*#__PURE__*/ _jsx(TextField, {
|
|
685
|
+
size: "small",
|
|
686
|
+
label: "Test a path",
|
|
687
|
+
placeholder: "/old-page",
|
|
688
|
+
value: testPath,
|
|
689
|
+
onChange: (event)=>setTestPath(event.target.value),
|
|
690
|
+
sx: {
|
|
691
|
+
maxWidth: 260
|
|
692
|
+
}
|
|
693
|
+
}),
|
|
694
|
+
testPath.trim() ? /*#__PURE__*/ _jsx(Typography, {
|
|
695
|
+
variant: "caption",
|
|
696
|
+
color: "text.secondary",
|
|
697
|
+
children: (()=>{
|
|
698
|
+
var _normalizeRedirectSource;
|
|
699
|
+
const normalized = (_normalizeRedirectSource = normalizeRedirectSource(testPath)) != null ? _normalizeRedirectSource : testPath.trim();
|
|
700
|
+
const result = matchRedirect(redirects, normalized);
|
|
701
|
+
return result ? `→ ${result.destination} (${result.statusCode})` : 'No rule matches';
|
|
702
|
+
})()
|
|
703
|
+
}) : null
|
|
704
|
+
]
|
|
705
|
+
})
|
|
706
|
+
]
|
|
707
|
+
}),
|
|
708
|
+
/*#__PURE__*/ _jsxs(Dialog, {
|
|
709
|
+
open: Boolean(draft),
|
|
710
|
+
onClose: ()=>setDraft(null),
|
|
711
|
+
maxWidth: "xs",
|
|
712
|
+
fullWidth: true,
|
|
713
|
+
children: [
|
|
714
|
+
/*#__PURE__*/ _jsx(DialogTitle, {
|
|
715
|
+
children: (draft == null ? void 0 : draft.id) ? 'Edit redirect' : 'Add redirect'
|
|
716
|
+
}),
|
|
717
|
+
/*#__PURE__*/ _jsxs(DialogContent, {
|
|
718
|
+
sx: {
|
|
719
|
+
display: 'flex',
|
|
720
|
+
flexDirection: 'column',
|
|
721
|
+
gap: 1.5,
|
|
722
|
+
pt: 1
|
|
723
|
+
},
|
|
724
|
+
children: [
|
|
725
|
+
/*#__PURE__*/ _jsxs(TextField, {
|
|
726
|
+
select: true,
|
|
727
|
+
label: "Match mode",
|
|
728
|
+
value: (_ref1 = draft == null ? void 0 : draft.kind) != null ? _ref1 : 'exact',
|
|
729
|
+
onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
|
|
730
|
+
kind: event.target.value
|
|
731
|
+
}) : prev),
|
|
732
|
+
size: "small",
|
|
733
|
+
sx: {
|
|
734
|
+
mt: 1
|
|
735
|
+
},
|
|
736
|
+
children: [
|
|
737
|
+
/*#__PURE__*/ _jsx(MenuItem, {
|
|
738
|
+
value: "exact",
|
|
739
|
+
children: 'Exact path'
|
|
740
|
+
}),
|
|
741
|
+
/*#__PURE__*/ _jsx(MenuItem, {
|
|
742
|
+
value: "prefix",
|
|
743
|
+
children: 'Path prefix'
|
|
744
|
+
}),
|
|
745
|
+
/*#__PURE__*/ _jsx(MenuItem, {
|
|
746
|
+
value: "regex",
|
|
747
|
+
children: 'Regular expression'
|
|
748
|
+
})
|
|
749
|
+
]
|
|
750
|
+
}),
|
|
751
|
+
/*#__PURE__*/ _jsx(TextField, {
|
|
752
|
+
label: (draft == null ? void 0 : draft.kind) === 'regex' ? 'Pattern' : 'From path',
|
|
753
|
+
placeholder: (draft == null ? void 0 : draft.kind) === 'regex' ? '/product/(\\d+)' : '/old-page',
|
|
754
|
+
helperText: (draft == null ? void 0 : draft.kind) === 'regex' ? 'Anchored to the whole path; use $1, $2 in the destination' : (draft == null ? void 0 : draft.kind) === 'prefix' ? 'Matches the path and everything under it' : undefined,
|
|
755
|
+
value: (_ref2 = draft == null ? void 0 : draft.source) != null ? _ref2 : '',
|
|
756
|
+
onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
|
|
757
|
+
source: event.target.value
|
|
758
|
+
}) : prev),
|
|
759
|
+
size: "small",
|
|
760
|
+
autoFocus: true
|
|
761
|
+
}),
|
|
762
|
+
/*#__PURE__*/ _jsx(TextField, {
|
|
763
|
+
label: "To",
|
|
764
|
+
placeholder: "/new-page or https://example.com",
|
|
765
|
+
value: (_ref3 = draft == null ? void 0 : draft.destination) != null ? _ref3 : '',
|
|
766
|
+
onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
|
|
767
|
+
destination: event.target.value
|
|
768
|
+
}) : prev),
|
|
769
|
+
size: "small"
|
|
770
|
+
}),
|
|
771
|
+
/*#__PURE__*/ _jsxs(TextField, {
|
|
772
|
+
select: true,
|
|
773
|
+
label: "Status code",
|
|
774
|
+
value: (_ref4 = draft == null ? void 0 : draft.statusCode) != null ? _ref4 : 302,
|
|
775
|
+
onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
|
|
776
|
+
statusCode: Number(event.target.value)
|
|
777
|
+
}) : prev),
|
|
778
|
+
size: "small",
|
|
779
|
+
children: [
|
|
780
|
+
/*#__PURE__*/ _jsx(MenuItem, {
|
|
781
|
+
value: 302,
|
|
782
|
+
children: '302 — temporary (default)'
|
|
783
|
+
}),
|
|
784
|
+
/*#__PURE__*/ _jsx(MenuItem, {
|
|
785
|
+
value: 301,
|
|
786
|
+
children: '301 — permanent'
|
|
787
|
+
}),
|
|
788
|
+
/*#__PURE__*/ _jsx(MenuItem, {
|
|
789
|
+
value: 307,
|
|
790
|
+
children: '307 — temporary, keep method'
|
|
791
|
+
}),
|
|
792
|
+
/*#__PURE__*/ _jsx(MenuItem, {
|
|
793
|
+
value: 308,
|
|
794
|
+
children: '308 — permanent, keep method'
|
|
795
|
+
})
|
|
796
|
+
]
|
|
797
|
+
}),
|
|
798
|
+
/*#__PURE__*/ _jsx(TextField, {
|
|
799
|
+
label: "Priority",
|
|
800
|
+
type: "number",
|
|
801
|
+
helperText: "Lower fires first when several rules match",
|
|
802
|
+
value: (_ref5 = draft == null ? void 0 : draft.priority) != null ? _ref5 : REDIRECT_DEFAULT_PRIORITY,
|
|
803
|
+
onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
|
|
804
|
+
priority: Number(event.target.value)
|
|
805
|
+
}) : prev),
|
|
806
|
+
size: "small"
|
|
807
|
+
})
|
|
808
|
+
]
|
|
809
|
+
}),
|
|
810
|
+
/*#__PURE__*/ _jsxs(DialogActions, {
|
|
811
|
+
children: [
|
|
812
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
813
|
+
onClick: ()=>setDraft(null),
|
|
814
|
+
children: 'Cancel'
|
|
815
|
+
}),
|
|
816
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
817
|
+
variant: "contained",
|
|
818
|
+
color: "primary",
|
|
819
|
+
disabled: !(draft == null ? void 0 : draft.source.trim()) || !(draft == null ? void 0 : draft.destination.trim()),
|
|
820
|
+
onClick: handleSave,
|
|
821
|
+
children: 'Save redirect'
|
|
822
|
+
})
|
|
823
|
+
]
|
|
824
|
+
})
|
|
825
|
+
]
|
|
826
|
+
})
|
|
827
|
+
]
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
RedirectsConsolePage.displayName = 'RedirectsConsolePage';
|
|
831
|
+
export default RedirectsConsolePage;
|
|
832
|
+
|
|
833
|
+
//# sourceMappingURL=redirects-console-page.js.map
|