@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.
@@ -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