this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(icon-access): per-project verification gate for SVG uploads

Projects can no longer upload SVG icons until an admin grants their
project access. This replaces the per-icon moderation queue with a
one-time per-project verification:

- profile gains icon_access_status (granted | requested | denied) plus
email/timestamp/reviewer/denied_reason columns (additive migration)
- POST /api/registry/icon-access/request lets a signed-in owner submit
a contact email; PUT /api/registry/profile rejects icon blobs from
non-granted projects
- public icon serving and the public profile API gate iconUrl on both
icon_status === approved AND icon_access_status === granted
- new admin page /admin/icon-access with grant / deny / revoke actions
(deny doubles as revoke); old /admin/icons + per-icon endpoints +
AdminIconReview island removed
- CreateProfileForm renders a locked / pending / denied / granted
banner and a Request Verification modal for collecting the contact
email; appeals routed to contact@atmosphereaccount.com
- minor: replace window.prompt with globalThis.prompt to satisfy
deno lint

Made-with: Cursor

+1378 -646
+88 -17
assets/styles.css
··· 2061 2061 border: 1px solid rgba(37, 74, 158, 0.2); 2062 2062 color: #254a9e; 2063 2063 box-shadow: 0 2px 6px rgba(48, 70, 128, 0.1); 2064 - transition: transform 0.18s ease, background 0.18s ease, 2065 - box-shadow 0.18s ease, color 0.18s ease, border-color 0.18s ease; 2064 + transition: 2065 + transform 0.18s ease, 2066 + background 0.18s ease, 2067 + box-shadow 0.18s ease, 2068 + color 0.18s ease, 2069 + border-color 0.18s ease; 2066 2070 pointer-events: none; 2067 2071 } 2068 2072 .profile-hero-button:hover .profile-hero-arrow, ··· 4273 4277 color: #ffaca6; 4274 4278 } 4275 4279 4276 - /* --- Inline icon-status banner on the manage form ---------------- */ 4277 - .icon-status-banner { 4280 + /* --- Per-project SVG verification gate (manage form) ------------- */ 4281 + /** 4282 + * Banner that explains the current verification state above the SVG 4283 + * uploader. Color reflects state: neutral grey for locked, amber for 4284 + * pending review, soft red for denied, no banner for granted. 4285 + */ 4286 + .icon-gate-banner { 4278 4287 display: flex; 4279 4288 flex-direction: column; 4280 - gap: 0.2rem; 4281 - padding: 0.65rem 0.85rem; 4282 - border-radius: 0.75rem; 4283 - font-size: 0.8rem; 4284 - margin: 0 0 0.75rem; 4289 + gap: 0.4rem; 4290 + align-items: flex-start; 4291 + padding: 0.85rem 1rem; 4292 + border-radius: 0.85rem; 4293 + font-size: 0.82rem; 4294 + margin: 0 0 0.85rem; 4295 + line-height: 1.45; 4296 + } 4297 + .icon-gate-banner-title { 4298 + font-size: 0.9rem; 4299 + font-weight: 700; 4300 + } 4301 + .icon-gate-banner-body { 4302 + display: block; 4285 4303 } 4286 - .icon-status-banner strong { 4287 - font-size: 0.85rem; 4304 + .icon-gate-banner-hint { 4305 + font-size: 0.75rem; 4306 + opacity: 0.7; 4288 4307 } 4289 - .icon-status-banner--pending { 4308 + .icon-gate-button { 4309 + margin-top: 0.25rem; 4310 + align-self: flex-start; 4311 + } 4312 + .icon-gate-banner--locked { 4313 + background: rgba(99, 113, 145, 0.1); 4314 + border: 1px solid rgba(99, 113, 145, 0.3); 4315 + color: rgba(38, 50, 78, 0.95); 4316 + } 4317 + .icon-gate-banner--pending { 4290 4318 background: rgba(255, 197, 99, 0.15); 4291 4319 border: 1px solid rgba(255, 197, 99, 0.4); 4292 4320 color: #6c4500; 4293 4321 } 4294 - .icon-status-banner--rejected { 4322 + .icon-gate-banner--denied { 4295 4323 background: rgba(217, 104, 96, 0.12); 4296 4324 border: 1px solid rgba(217, 104, 96, 0.4); 4297 4325 color: #8a3a34; 4298 4326 } 4299 - .dark-phase .icon-status-banner--pending { 4327 + .icon-gate-granted-hint { 4328 + color: rgba(11, 110, 79, 0.95); 4329 + } 4330 + .dark-phase .icon-gate-banner--locked { 4331 + background: rgba(255, 255, 255, 0.04); 4332 + border-color: rgba(255, 255, 255, 0.12); 4333 + color: rgba(255, 255, 255, 0.85); 4334 + } 4335 + .dark-phase .icon-gate-banner--pending { 4300 4336 color: #ffd791; 4301 4337 background: rgba(255, 197, 99, 0.1); 4302 4338 } 4303 - .dark-phase .icon-status-banner--rejected { 4339 + .dark-phase .icon-gate-banner--denied { 4304 4340 color: #ffb1ab; 4305 4341 background: rgba(217, 104, 96, 0.12); 4342 + } 4343 + .dark-phase .icon-gate-granted-hint { 4344 + color: #8be0b3; 4345 + } 4346 + 4347 + /** 4348 + * Greyed-out uploader when verification hasn't been granted. We keep 4349 + * the slot visible (so the user understands what they're requesting 4350 + * access to) but visibly inert. 4351 + */ 4352 + .profile-form-icon-row.is-locked { 4353 + opacity: 0.45; 4354 + pointer-events: none; 4355 + filter: saturate(0.5); 4356 + } 4357 + .profile-form-button-secondary.is-disabled, 4358 + .profile-form-button-secondary.is-disabled:hover { 4359 + opacity: 0.55; 4360 + cursor: not-allowed; 4361 + background: transparent; 4362 + } 4363 + 4364 + /* --- Verification request modal --------------------------------- */ 4365 + .icon-access-modal { 4366 + width: min(460px, 100%); 4367 + } 4368 + .modal-actions { 4369 + display: flex; 4370 + align-items: center; 4371 + gap: 0.85rem; 4372 + flex-wrap: wrap; 4306 4373 } 4307 4374 4308 4375 /* ================================================================== * ··· 4554 4621 .admin-featured-status { 4555 4622 font-size: 0.85rem; 4556 4623 } 4557 - .admin-featured-status--ok { color: #1f7a4e; } 4558 - .admin-featured-status--error { color: #c25048; } 4624 + .admin-featured-status--ok { 4625 + color: #1f7a4e; 4626 + } 4627 + .admin-featured-status--error { 4628 + color: #c25048; 4629 + } 4559 4630 .dark-phase .admin-featured-row { 4560 4631 background: rgba(255, 255, 255, 0.05); 4561 4632 border-color: rgba(255, 255, 255, 0.1);
+5 -5
components/Footer.tsx
··· 39 39 > 40 40 {t.footer.links.atProtocol} 41 41 </a> 42 - {/* Hide on the explore section — visitors are already there, 43 - * so the link would just point at the page they're on. */} 44 - {!compact && ( 45 - <a href="/explore">{t.footer.links.exploreApps}</a> 46 - )} 42 + { 43 + /* Hide on the explore section — visitors are already there, 44 + * so the link would just point at the page they're on. */ 45 + } 46 + {!compact && <a href="/explore">{t.footer.links.exploreApps}</a>} 47 47 <a href="/developer-resources">{t.footer.links.developerResources}</a> 48 48 </div> 49 49 {!compact && <p class="footer-quote">{t.footer.quote()}</p>}
+4 -2
components/Nav.tsx
··· 28 28 <span class="nav-logo-text">{t.nav.brand}</span> 29 29 </a> 30 30 <div class="nav-links"> 31 - {/* Protocol moved to the footer — the top-right slot now 31 + { 32 + /* Protocol moved to the footer — the top-right slot now 32 33 * belongs to Explore (the primary call to action) with the 33 - * account button stacked beneath it via the rail below. */} 34 + * account button stacked beneath it via the rail below. */ 35 + } 34 36 <a href="/explore" class="nav-btn nav-btn-glass"> 35 37 {t.nav.explore} 36 38 </a>
+7 -2
components/explore/ProfileLinks.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 - import { resolveLink, type ResolvedIconKind } from "../../lib/atmosphere-links.ts"; 2 + import { 3 + type ResolvedIconKind, 4 + resolveLink, 5 + } from "../../lib/atmosphere-links.ts"; 3 6 import { useT } from "../../i18n/mod.ts"; 4 7 import BskyIcon from "../icons/BskyIcon.tsx"; 5 8 import TangledIcon from "../icons/TangledIcon.tsx"; ··· 36 39 <div class="profile-actions"> 37 40 {resolved.map((r, i) => ( 38 41 <a 39 - class={i === 0 ? "profile-action profile-action--primary" : "profile-action"} 42 + class={i === 0 43 + ? "profile-action profile-action--primary" 44 + : "profile-action"} 40 45 href={r.href} 41 46 target="_blank" 42 47 rel="noopener noreferrer"
+61 -23
i18n/messages/en.tsx
··· 608 608 remove: "Remove SVG", 609 609 invalidType: "Icon must be an SVG (image/svg+xml).", 610 610 tooLarge: "Icon must be 200KB or smaller.", 611 - statusPendingTitle: "Pending review", 612 - statusPendingBody: 613 - "Your icon is on your PDS but won't appear in the developer API until an admin approves it.", 614 - statusRejectedTitle: "Rejected", 615 - statusRejectedBody: (reason: string): string => 616 - `An admin rejected this icon. Reason: ${reason}. Replace it below to resubmit.`, 611 + gate: { 612 + /** Gate state when the project hasn't requested verification yet. */ 613 + lockedTitle: "SVG Upload requires verification", 614 + lockedBody: 615 + "Per-project verification keeps malicious SVGs out of the developer API. Submit a request and an admin will review your project.", 616 + requestButton: "Request Verification", 617 + /** Disabled-button text shown before the user has published their profile. */ 618 + requestDisabledHint: 619 + "Publish your profile first, then come back here to request verification.", 620 + /** Gate state while a request is sitting in the admin queue. */ 621 + pendingTitle: "Verification request pending", 622 + pendingBody: (email: string): string => 623 + `An admin will review your request and reply to ${email}.`, 624 + /** Gate state after admin denial. */ 625 + deniedTitle: "Verification denied", 626 + deniedBody: (appealEmail: string, reason: string | null): string => 627 + reason 628 + ? `An admin denied your verification request. Reason: ${reason}. To appeal, email ${appealEmail}.` 629 + : `An admin denied your verification request. To appeal, email ${appealEmail}.`, 630 + /** Gate state after admin grant — uploader unlocked. */ 631 + grantedHint: 632 + "Your project is verified — SVG uploads are unlocked. Files are still sanitised on upload.", 633 + }, 634 + /** Modal that collects a contact email for the verification request. */ 635 + requestModal: { 636 + title: "Request SVG icon verification", 637 + body: 638 + "An admin will review your project and reply by email. We only use this address for this verification thread.", 639 + emailLabel: "Contact email", 640 + emailPlaceholder: "you@example.com", 641 + submit: "Submit request", 642 + cancel: "Cancel", 643 + submitting: "Submitting…", 644 + successTitle: "Request submitted", 645 + successBody: "An admin will review your project and reply by email.", 646 + invalidEmail: "Enter a valid email address.", 647 + /** Generic failure surface — server text appended after. */ 648 + errorPrefix: "Couldn't submit request", 649 + }, 617 650 }, 618 651 }, 619 652 }, ··· 628 661 overview: { 629 662 headline: "Admin", 630 663 subhead: 631 - "Approve developer icons, triage reports, and curate the featured rail.", 632 - iconsTitle: "Pending icons", 633 - iconsBody: 634 - "Developer-facing SVGs awaiting approval before they're served via the public API.", 664 + "Verify projects for SVG uploads, triage reports, and curate the featured rail.", 665 + iconAccessTitle: "Icon access requests", 666 + iconAccessBody: 667 + "Projects requesting permission to upload an SVG icon for the developer API.", 635 668 reportsTitle: "Open reports", 636 669 reportsBody: "User-submitted reports against profiles in Explore.", 637 670 featuredTitle: "Featured", ··· 646 679 approved: "Approved", 647 680 rejected: "Rejected", 648 681 }, 649 - icons: { 650 - headline: "Pending developer icons", 682 + iconAccess: { 683 + headline: "Icon access requests", 651 684 subhead: 652 - "Each icon was uploaded to the project's PDS and sanitised, but won't be served via /api/registry/icon/:did until you approve it.", 653 - empty: "Nothing in the queue. Check back later.", 654 - approve: "Approve", 655 - reject: "Reject", 656 - confirmReject: "Reject this icon. Tell the project owner why:", 657 - rejectReasonPlaceholder: 658 - "e.g. unrelated to the project, low quality, contains text", 659 - submitReject: "Submit rejection", 660 - cancel: "Cancel", 661 - markedApproved: "Approved", 662 - markedRejected: "Rejected", 685 + "Projects asking for permission to upload an SVG icon. Granting unlocks /api/registry/icon/:did and the developer API's `iconUrl`. Per-icon sanitisation still runs server-side. Denying (or revoking) hides any existing icon immediately.", 686 + pendingHeading: "Pending requests", 687 + grantedHeading: "Currently verified", 688 + emptyPending: "No requests in the queue.", 689 + emptyGranted: "No projects are verified yet.", 690 + grant: "Grant", 691 + deny: "Deny", 692 + revoke: "Revoke", 693 + denyPrompt: 694 + "Optional: tell the project owner why you're denying / revoking. Press OK with the field empty to deny without a reason.", 695 + markedGranted: "Granted", 696 + markedDenied: "Denied", 697 + requestedAtLabel: "Requested", 698 + grantedAtLabel: "Granted", 699 + emailLabel: "Contact email", 700 + viewProfile: "View profile", 663 701 }, 664 702 reports: { 665 703 headline: "Open reports",
+7 -5
islands/AccountMenu.tsx
··· 39 39 ); 40 40 } 41 41 42 - return <SignedInMenu 43 - user={user} 44 - avatarUrl={avatarUrl ?? null} 45 - publicProfileHandle={publicProfileHandle ?? null} 46 - />; 42 + return ( 43 + <SignedInMenu 44 + user={user} 45 + avatarUrl={avatarUrl ?? null} 46 + publicProfileHandle={publicProfileHandle ?? null} 47 + /> 48 + ); 47 49 } 48 50 49 51 interface SignedInMenuProps {
+5 -2
islands/AdminFeaturedEditor.tsx
··· 62 62 return m; 63 63 }); 64 64 65 - const featuredDids = useComputed(() => new Set(entries.value.map((e) => e.did))); 65 + const featuredDids = useComputed(() => 66 + new Set(entries.value.map((e) => e.did)) 67 + ); 66 68 67 69 const filteredCandidates = useComputed(() => { 68 70 const q = filter.value.trim().toLowerCase(); ··· 236 238 <button 237 239 type="button" 238 240 class="profile-form-button-secondary" 239 - onClick={() => add(c.did)} 241 + onClick={() => 242 + add(c.did)} 240 243 > 241 244 {copy.add} 242 245 </button>
+75
islands/AdminIconAccessRevoke.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + did: string; 5 + label: string; 6 + promptText: string; 7 + doneLabel: string; 8 + errorPrefix: string; 9 + } 10 + 11 + /** 12 + * Compact revoke button used per-row on the granted list of 13 + * /admin/icon-access. Posts to the same `deny` endpoint the pending 14 + * queue uses, since "revoke a granted project" and "deny a pending 15 + * request" land the row in the same `denied` state. 16 + */ 17 + export default function AdminIconAccessRevoke(p: Props) { 18 + const status = useSignal< 19 + | { kind: "idle" } 20 + | { kind: "submitting" } 21 + | { kind: "done" } 22 + | { kind: "error"; text: string } 23 + >({ kind: "idle" }); 24 + 25 + const onClick = async () => { 26 + const reason = globalThis.prompt(p.promptText, ""); 27 + // null = cancelled; empty string = no reason but proceed. 28 + if (reason === null) return; 29 + status.value = { kind: "submitting" }; 30 + try { 31 + const r = await fetch( 32 + `/api/admin/icon-access/${encodeURIComponent(p.did)}/deny`, 33 + { 34 + method: "POST", 35 + headers: { "content-type": "application/json" }, 36 + body: JSON.stringify({ reason: reason.trim() || undefined }), 37 + }, 38 + ); 39 + if (!r.ok) throw new Error(await r.text()); 40 + status.value = { kind: "done" }; 41 + } catch (err) { 42 + status.value = { 43 + kind: "error", 44 + text: err instanceof Error ? err.message : String(err), 45 + }; 46 + } 47 + }; 48 + 49 + if (status.value.kind === "done") { 50 + return ( 51 + <span class="admin-status-badge admin-status-badge--rejected"> 52 + {p.doneLabel} 53 + </span> 54 + ); 55 + } 56 + 57 + const submitting = status.value.kind === "submitting"; 58 + return ( 59 + <> 60 + <button 61 + type="button" 62 + class="profile-form-button-secondary" 63 + onClick={onClick} 64 + disabled={submitting} 65 + > 66 + {submitting ? "…" : p.label} 67 + </button> 68 + {status.value.kind === "error" && ( 69 + <p class="admin-icon-row-error"> 70 + {p.errorPrefix}: {status.value.text} 71 + </p> 72 + )} 73 + </> 74 + ); 75 + }
+159
islands/AdminIconAccessRow.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + did: string; 5 + handle: string; 6 + name: string; 7 + /** Contact email captured at request time (always present for `requested` rows). */ 8 + email: string; 9 + /** ms epoch — when the row entered the `requested` state. */ 10 + requestedAt: number; 11 + copy: { 12 + grant: string; 13 + deny: string; 14 + denyPrompt: string; 15 + grantedLabel: string; 16 + deniedLabel: string; 17 + requestedAtLabel: string; 18 + emailLabel: string; 19 + viewProfile: string; 20 + error: string; 21 + }; 22 + } 23 + 24 + type Status = 25 + | { kind: "idle" } 26 + | { kind: "submitting" } 27 + | { kind: "granted" } 28 + | { kind: "denied" } 29 + | { kind: "error"; text: string }; 30 + 31 + /** 32 + * Per-row Grant / Deny widget on /admin/icon-access. Mirrors the 33 + * structure of AdminReportRow — the grid layout is server-rendered 34 + * around it, this island only owns the buttons and the optimistic 35 + * "Granted / Denied" state pill. 36 + */ 37 + export default function AdminIconAccessRow(p: Props) { 38 + const status = useSignal<Status>({ kind: "idle" }); 39 + 40 + const grant = async () => { 41 + status.value = { kind: "submitting" }; 42 + try { 43 + const r = await fetch( 44 + `/api/admin/icon-access/${encodeURIComponent(p.did)}/grant`, 45 + { method: "POST" }, 46 + ); 47 + if (!r.ok) throw new Error(await r.text()); 48 + status.value = { kind: "granted" }; 49 + } catch (err) { 50 + status.value = { 51 + kind: "error", 52 + text: err instanceof Error ? err.message : String(err), 53 + }; 54 + } 55 + }; 56 + 57 + const deny = async () => { 58 + const reason = globalThis.prompt(p.copy.denyPrompt, ""); 59 + // An empty / cancelled prompt means abort — but a valid empty 60 + // string from the user is allowed; deny without a reason is fine 61 + // (admin may not want to share their rationale). 62 + if (reason === null) return; 63 + status.value = { kind: "submitting" }; 64 + try { 65 + const r = await fetch( 66 + `/api/admin/icon-access/${encodeURIComponent(p.did)}/deny`, 67 + { 68 + method: "POST", 69 + headers: { "content-type": "application/json" }, 70 + body: JSON.stringify({ reason: reason.trim() || undefined }), 71 + }, 72 + ); 73 + if (!r.ok) throw new Error(await r.text()); 74 + status.value = { kind: "denied" }; 75 + } catch (err) { 76 + status.value = { 77 + kind: "error", 78 + text: err instanceof Error ? err.message : String(err), 79 + }; 80 + } 81 + }; 82 + 83 + if (status.value.kind === "granted") { 84 + return ( 85 + <div class="admin-icon-row admin-icon-row--done"> 86 + <strong>{p.name}</strong>{" "} 87 + <span class="admin-status-badge admin-status-badge--approved"> 88 + {p.copy.grantedLabel} 89 + </span> 90 + </div> 91 + ); 92 + } 93 + if (status.value.kind === "denied") { 94 + return ( 95 + <div class="admin-icon-row admin-icon-row--done"> 96 + <strong>{p.name}</strong>{" "} 97 + <span class="admin-status-badge admin-status-badge--rejected"> 98 + {p.copy.deniedLabel} 99 + </span> 100 + </div> 101 + ); 102 + } 103 + 104 + const requested = new Date(p.requestedAt).toISOString().slice(0, 10); 105 + const submitting = status.value.kind === "submitting"; 106 + 107 + return ( 108 + <div class="admin-icon-row"> 109 + <div class="admin-icon-row-meta"> 110 + <p class="admin-icon-row-name"> 111 + <strong>{p.name}</strong> 112 + <span class="admin-icon-row-handle"> 113 + <a 114 + href={`/explore/${encodeURIComponent(p.handle)}`} 115 + target="_blank" 116 + rel="noopener noreferrer" 117 + class="text-link-button" 118 + > 119 + @{p.handle} ↗ 120 + </a> 121 + </span> 122 + </p> 123 + <p class="admin-icon-row-did"> 124 + <code>{p.did}</code> 125 + </p> 126 + <p class="admin-icon-row-uploaded"> 127 + <strong>{p.copy.emailLabel}:</strong>{" "} 128 + <a href={`mailto:${p.email}`} class="text-link-button">{p.email}</a> 129 + </p> 130 + <p class="admin-icon-row-uploaded"> 131 + {p.copy.requestedAtLabel} {requested} 132 + </p> 133 + </div> 134 + <div class="admin-icon-row-actions"> 135 + <button 136 + type="button" 137 + class="profile-form-button-primary" 138 + onClick={grant} 139 + disabled={submitting} 140 + > 141 + {submitting ? "…" : p.copy.grant} 142 + </button> 143 + <button 144 + type="button" 145 + class="profile-form-button-secondary" 146 + onClick={deny} 147 + disabled={submitting} 148 + > 149 + {p.copy.deny} 150 + </button> 151 + {status.value.kind === "error" && ( 152 + <p class="admin-icon-row-error"> 153 + {p.copy.error}: {status.value.text} 154 + </p> 155 + )} 156 + </div> 157 + </div> 158 + ); 159 + }
-186
islands/AdminIconReview.tsx
··· 1 - import { useSignal } from "@preact/signals"; 2 - 3 - interface Props { 4 - did: string; 5 - handle: string; 6 - name: string; 7 - /** Admin-only preview URL — bypasses the public approval gate. */ 8 - previewUrl: string; 9 - /** When the icon was uploaded / last reindexed. */ 10 - uploadedAt: number; 11 - copy: { 12 - approve: string; 13 - reject: string; 14 - rejectReasonPlaceholder: string; 15 - confirmReject: string; 16 - submit: string; 17 - cancel: string; 18 - pending: string; 19 - approved: string; 20 - rejected: string; 21 - error: string; 22 - }; 23 - } 24 - 25 - type Status = "pending" | "approving" | "approved" | "rejecting" | "rejected"; 26 - 27 - /** 28 - * Per-row review widget on /admin/icons. Server renders the static 29 - * project info; this island owns the buttons + reject-reason flow and 30 - * removes the row from the DOM optimistically once the API returns. 31 - */ 32 - export default function AdminIconReview( 33 - { did, handle, name, previewUrl, uploadedAt, copy }: Props, 34 - ) { 35 - const status = useSignal<Status>("pending"); 36 - const error = useSignal<string | null>(null); 37 - const showReject = useSignal(false); 38 - const reason = useSignal(""); 39 - 40 - const onApprove = async () => { 41 - status.value = "approving"; 42 - error.value = null; 43 - try { 44 - const r = await fetch( 45 - `/api/admin/icons/${encodeURIComponent(did)}/approve`, 46 - { method: "POST" }, 47 - ); 48 - if (!r.ok) throw new Error(await r.text()); 49 - status.value = "approved"; 50 - } catch (e) { 51 - error.value = e instanceof Error ? e.message : String(e); 52 - status.value = "pending"; 53 - } 54 - }; 55 - 56 - const onReject = async () => { 57 - const text = reason.value.trim(); 58 - if (!text) return; 59 - status.value = "rejecting"; 60 - error.value = null; 61 - try { 62 - const r = await fetch( 63 - `/api/admin/icons/${encodeURIComponent(did)}/reject`, 64 - { 65 - method: "POST", 66 - headers: { "content-type": "application/json" }, 67 - body: JSON.stringify({ reason: text }), 68 - }, 69 - ); 70 - if (!r.ok) throw new Error(await r.text()); 71 - status.value = "rejected"; 72 - showReject.value = false; 73 - } catch (e) { 74 - error.value = e instanceof Error ? e.message : String(e); 75 - status.value = "pending"; 76 - } 77 - }; 78 - 79 - if (status.value === "approved") { 80 - return ( 81 - <div class="admin-icon-row admin-icon-row--done"> 82 - <strong>{name}</strong>{" "} 83 - <span class="admin-status-badge admin-status-badge--approved"> 84 - {copy.approved} 85 - </span> 86 - </div> 87 - ); 88 - } 89 - if (status.value === "rejected") { 90 - return ( 91 - <div class="admin-icon-row admin-icon-row--done"> 92 - <strong>{name}</strong>{" "} 93 - <span class="admin-status-badge admin-status-badge--rejected"> 94 - {copy.rejected} 95 - </span> 96 - </div> 97 - ); 98 - } 99 - 100 - const uploaded = new Date(uploadedAt).toISOString().slice(0, 10); 101 - 102 - return ( 103 - <div class="admin-icon-row"> 104 - <div class="admin-icon-row-preview"> 105 - <img src={previewUrl} alt="" class="admin-icon-row-img" /> 106 - </div> 107 - <div class="admin-icon-row-meta"> 108 - <p class="admin-icon-row-name"> 109 - <strong>{name}</strong> 110 - <span class="admin-icon-row-handle">@{handle}</span> 111 - </p> 112 - <p class="admin-icon-row-did"> 113 - <code>{did}</code> 114 - </p> 115 - <p class="admin-icon-row-uploaded">Uploaded {uploaded}</p> 116 - </div> 117 - <div class="admin-icon-row-actions"> 118 - {!showReject.value 119 - ? ( 120 - <> 121 - <button 122 - type="button" 123 - class="profile-form-button-primary" 124 - onClick={onApprove} 125 - disabled={status.value === "approving"} 126 - > 127 - {status.value === "approving" ? "…" : copy.approve} 128 - </button> 129 - <button 130 - type="button" 131 - class="profile-form-button-secondary" 132 - onClick={() => { 133 - showReject.value = true; 134 - }} 135 - > 136 - {copy.reject} 137 - </button> 138 - </> 139 - ) 140 - : ( 141 - <div class="admin-icon-reject"> 142 - <label class="admin-icon-reject-label"> 143 - {copy.confirmReject} 144 - <textarea 145 - class="admin-icon-reject-input" 146 - rows={3} 147 - maxLength={500} 148 - placeholder={copy.rejectReasonPlaceholder} 149 - value={reason.value} 150 - onInput={(e) => 151 - reason.value = (e.currentTarget as HTMLTextAreaElement) 152 - .value} 153 - /> 154 - </label> 155 - <div class="admin-icon-reject-actions"> 156 - <button 157 - type="button" 158 - class="profile-form-button-primary" 159 - onClick={onReject} 160 - disabled={status.value === "rejecting" || 161 - !reason.value.trim()} 162 - > 163 - {status.value === "rejecting" ? "…" : copy.submit} 164 - </button> 165 - <button 166 - type="button" 167 - class="profile-form-button-link" 168 - onClick={() => { 169 - showReject.value = false; 170 - reason.value = ""; 171 - }} 172 - > 173 - {copy.cancel} 174 - </button> 175 - </div> 176 - </div> 177 - )} 178 - {error.value && ( 179 - <p class="admin-icon-row-error"> 180 - {copy.error}: {error.value} 181 - </p> 182 - )} 183 - </div> 184 - </div> 185 - ); 186 - }
+1 -1
islands/AdminReportRow.tsx
··· 72 72 * sibling reports also disappear on the next page load). 73 73 */ 74 74 const takedown = async () => { 75 - const reason = window.prompt(p.copy.takedownPrompt, ""); 75 + const reason = globalThis.prompt(p.copy.takedownPrompt, ""); 76 76 if (!reason || !reason.trim()) return; 77 77 status.value = { kind: "submitting" }; 78 78 try {
+229 -39
islands/CreateProfileForm.tsx
··· 31 31 subcategories: string[]; 32 32 links: LinkEntry[]; 33 33 avatar: { ref: string; mime: string } | null; 34 - /** Optional developer-facing SVG icon. `status` is the moderation 35 - * state from the registry index — drives the "Pending review" / 36 - * "Rejected" badge in the icon section. */ 34 + /** Optional developer-facing SVG icon. */ 37 35 icon: 38 36 | { 39 37 ref: string; 40 38 mime: string; 41 - status?: "pending" | "approved" | "rejected" | null; 42 - rejectedReason?: string | null; 43 39 } 44 40 | null; 41 + /** 42 + * Per-project verification gate for the SVG icon uploader. Drives the 43 + * locked / pending / denied / granted UX in the icon section. 44 + * - `null` → never requested; show "Request Verification" 45 + * - `requested` → in admin queue; show pending state 46 + * - `granted` → uploader unlocked 47 + * - `denied` → admin denied; show appeal email; locked 48 + */ 49 + iconAccessStatus: "requested" | "granted" | "denied" | null; 50 + iconAccessEmail: string | null; 51 + iconAccessDeniedReason: string | null; 45 52 } 53 + 54 + /** 55 + * Email address surfaced in the denial banner so users know how to 56 + * appeal. Centralised here because it appears in user-facing copy. 57 + */ 58 + const APPEAL_EMAIL = "contact@atmosphereaccount.com"; 46 59 47 60 interface Props { 48 61 did: string; ··· 181 194 const promoteLegacyWebsite = !initial?.mainLink && 182 195 !!initialSplit.legacyWebsite; 183 196 const mainLink = useSignal<string>( 184 - initial?.mainLink ?? (promoteLegacyWebsite ? initialSplit.legacyWebsite : ""), 197 + initial?.mainLink ?? 198 + (promoteLegacyWebsite ? initialSplit.legacyWebsite : ""), 185 199 ); 186 200 const categories = useSignal<string[]>( 187 201 initial?.categories?.length ? initial.categories : ["app"], ··· 242 256 /** 243 257 * SVG icons get a separate slot from the main avatar — the avatar is 244 258 * for the public profile, the icon is a vector mark exposed only via 245 - * the developer API. We store the keep/upload/remove state in the 246 - * same shape as avatar for consistency on Save. 259 + * the developer API. The uploader is gated behind per-project 260 + * verification (`iconAccessStatus === 'granted'`) — the gate is the 261 + * source of truth client-side AND server-side; the API rejects 262 + * uploads from unverified projects too. 247 263 */ 248 264 const iconKeep = useSignal<BlobRefShape | null>(null); 249 265 const iconPreviewUrl = useSignal<string | null>( ··· 252 268 const iconFile = useSignal<File | null>(null); 253 269 const iconRemoved = useSignal(false); 254 270 271 + /** 272 + * Live access status. Starts from the value the server rendered, then 273 + * flips to `requested` when the user submits the request modal so the 274 + * UI updates without a page reload. 275 + */ 276 + const iconAccessStatus = useSignal< 277 + "requested" | "granted" | "denied" | null 278 + >(initial?.iconAccessStatus ?? null); 279 + const iconAccessEmail = useSignal<string | null>( 280 + initial?.iconAccessEmail ?? null, 281 + ); 282 + const iconAccessDeniedReason = initial?.iconAccessDeniedReason ?? null; 283 + const iconUploadUnlocked = iconAccessStatus.value === "granted"; 284 + 285 + /* ---------------- Verification request modal signals ----------------- */ 286 + const requestModalOpen = useSignal(false); 287 + const requestEmail = useSignal(""); 288 + const requestSubmitting = useSignal(false); 289 + const requestError = useSignal<string | null>(null); 290 + 255 291 const submitting = useSignal(false); 256 292 const deleting = useSignal(false); 257 293 const message = useSignal<{ kind: "ok" | "error"; text: string } | null>( ··· 369 405 }; 370 406 371 407 /** 408 + * Submit the verification request to the server. We optimistically 409 + * update `iconAccessStatus` to `requested` so the gate UI flips 410 + * immediately on success without a reload. 411 + */ 412 + const submitVerificationRequest = async (event: Event) => { 413 + event.preventDefault(); 414 + if (requestSubmitting.value) return; 415 + const email = requestEmail.value.trim(); 416 + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { 417 + requestError.value = tIcon.requestModal.invalidEmail; 418 + return; 419 + } 420 + requestSubmitting.value = true; 421 + requestError.value = null; 422 + try { 423 + const r = await fetch("/api/registry/icon-access/request", { 424 + method: "POST", 425 + headers: { "content-type": "application/json" }, 426 + body: JSON.stringify({ email }), 427 + }); 428 + if (!r.ok) { 429 + const text = await r.text(); 430 + throw new Error(text || `HTTP ${r.status}`); 431 + } 432 + iconAccessStatus.value = "requested"; 433 + iconAccessEmail.value = email; 434 + requestModalOpen.value = false; 435 + } catch (err) { 436 + requestError.value = err instanceof Error ? err.message : String(err); 437 + } finally { 438 + requestSubmitting.value = false; 439 + } 440 + }; 441 + 442 + /** 372 443 * Reduce the form's working state into the lexicon-shaped LinkEntry[] 373 444 * we send to the API. Order matters for the public profile button row 374 445 * — atmosphere links first (in service order, with the user's chosen ··· 663 734 )} 664 735 665 736 {/* ---------------- Main Link ----------------------------- */} 666 - {/* 737 + { 738 + /* 667 739 Required. Drives the listing card's link target on /explore 668 740 (whole card becomes a button). Also surfaced as a small 669 741 arrow on hover. We keep it directly above Atmosphere links ··· 671 743 the card goes (Main Link) → who runs the project (Atmosphere 672 744 services) → optional secondary surfaces (Landing Page + 673 745 custom). 674 - */} 746 + */ 747 + } 675 748 <label class="profile-form-field"> 676 749 <span class="profile-form-label"> 677 750 {tMainLink.sectionLabel}{" "} ··· 711 784 </fieldset> 712 785 713 786 {/* ---------------- Landing Page (optional) --------------- */} 714 - {/* 787 + { 788 + /* 715 789 Optional secondary URL — a separate marketing/landing page 716 790 distinct from the Main Link. Renders as the globe-icon 717 791 button on /explore/<handle>. Stored as `kind: website` for 718 792 backward compatibility with existing records. 719 - */} 793 + */ 794 + } 720 795 <label class="profile-form-field"> 721 796 <span class="profile-form-label">{tLanding.sectionLabel}</span> 722 797 <input ··· 725 800 placeholder={tLanding.placeholder} 726 801 value={landingPage.value} 727 802 onInput={(e) => 728 - landingPage.value = 729 - (e.currentTarget as HTMLInputElement).value} 803 + landingPage.value = (e.currentTarget as HTMLInputElement).value} 730 804 /> 731 805 <p class="profile-form-hint">{tLanding.hint}</p> 732 806 </label> ··· 784 858 /* 785 859 Vector mark exposed only via /api/registry/icon/:did, for 786 860 developers building badges and app showcases. Not shown on 787 - the public Explore profile. Kept visually lightweight so it 788 - doesn't compete with the main avatar slot above. 861 + the public Explore profile. Uploads are gated behind 862 + per-project verification — admin-granted only. 789 863 */ 790 864 } 791 - <fieldset class="profile-form-field"> 865 + <fieldset 866 + class={`profile-form-field icon-section icon-section--${ 867 + iconAccessStatus.value ?? "locked" 868 + }`} 869 + > 792 870 <legend class="profile-form-label">{tIcon.sectionLabel}</legend> 793 - {/** 794 - * Surface the moderation state for the *currently saved* 795 - * icon so the user knows whether the developer API is 796 - * actually serving it. We only show a badge when the user 797 - * hasn't queued a replacement (otherwise the badge would 798 - * lie until the next save). 799 - */} 800 - {!iconFile.value && !iconRemoved.value && initial?.icon && 801 - initial.icon.status === "pending" && ( 802 - <div class="icon-status-banner icon-status-banner--pending"> 803 - <strong>{tIcon.statusPendingTitle}</strong> 804 - <span>{tIcon.statusPendingBody}</span> 871 + 872 + {/* ---- Gate banners (one of these renders per state) ---- */} 873 + {iconAccessStatus.value === null && ( 874 + <div class="icon-gate-banner icon-gate-banner--locked"> 875 + <strong class="icon-gate-banner-title"> 876 + {tIcon.gate.lockedTitle} 877 + </strong> 878 + <span class="icon-gate-banner-body"> 879 + {tIcon.gate.lockedBody} 880 + </span> 881 + <button 882 + type="button" 883 + class="profile-form-button-secondary icon-gate-button" 884 + onClick={() => { 885 + requestError.value = null; 886 + requestEmail.value = ""; 887 + requestModalOpen.value = true; 888 + }} 889 + disabled={!published.value} 890 + title={published.value 891 + ? undefined 892 + : tIcon.gate.requestDisabledHint} 893 + > 894 + {tIcon.gate.requestButton} 895 + </button> 896 + {!published.value && ( 897 + <span class="icon-gate-banner-hint"> 898 + {tIcon.gate.requestDisabledHint} 899 + </span> 900 + )} 805 901 </div> 806 902 )} 807 - {!iconFile.value && !iconRemoved.value && initial?.icon && 808 - initial.icon.status === "rejected" && ( 809 - <div class="icon-status-banner icon-status-banner--rejected"> 810 - <strong>{tIcon.statusRejectedTitle}</strong> 811 - <span> 812 - {tIcon.statusRejectedBody( 813 - initial.icon.rejectedReason ?? "(no reason given)", 903 + {iconAccessStatus.value === "requested" && ( 904 + <div class="icon-gate-banner icon-gate-banner--pending"> 905 + <strong class="icon-gate-banner-title"> 906 + {tIcon.gate.pendingTitle} 907 + </strong> 908 + <span class="icon-gate-banner-body"> 909 + {tIcon.gate.pendingBody( 910 + iconAccessEmail.value ?? APPEAL_EMAIL, 814 911 )} 815 912 </span> 816 913 </div> 817 914 )} 818 - <div class="profile-form-icon-row"> 915 + {iconAccessStatus.value === "denied" && ( 916 + <div class="icon-gate-banner icon-gate-banner--denied"> 917 + <strong class="icon-gate-banner-title"> 918 + {tIcon.gate.deniedTitle} 919 + </strong> 920 + <span class="icon-gate-banner-body"> 921 + {tIcon.gate.deniedBody(APPEAL_EMAIL, iconAccessDeniedReason)} 922 + </span> 923 + </div> 924 + )} 925 + {iconAccessStatus.value === "granted" && ( 926 + <p class="profile-form-hint icon-gate-granted-hint"> 927 + {tIcon.gate.grantedHint} 928 + </p> 929 + )} 930 + 931 + {/* ---- Uploader (greyed-out unless granted) ---- */} 932 + <div 933 + class={`profile-form-icon-row ${ 934 + iconUploadUnlocked ? "" : "is-locked" 935 + }`} 936 + > 819 937 <div class="profile-form-icon-preview" aria-hidden="true"> 820 938 {iconPreviewUrl.value 821 939 ? ( ··· 831 949 : <span class="profile-form-icon-placeholder">SVG</span>} 832 950 </div> 833 951 <div class="profile-form-icon-actions"> 834 - <label class="profile-form-button-secondary"> 952 + <label 953 + class={`profile-form-button-secondary ${ 954 + iconUploadUnlocked ? "" : "is-disabled" 955 + }`} 956 + aria-disabled={!iconUploadUnlocked} 957 + > 835 958 {iconPreviewUrl.value ? tIcon.replace : tIcon.upload} 836 959 <input 837 960 type="file" 838 961 accept="image/svg+xml" 839 962 hidden 963 + disabled={!iconUploadUnlocked} 840 964 onChange={onIconChange} 841 965 /> 842 966 </label> 843 - {iconPreviewUrl.value && ( 967 + {iconPreviewUrl.value && iconUploadUnlocked && ( 844 968 <button 845 969 type="button" 846 970 class="profile-form-button-link" ··· 906 1030 </span> 907 1031 )} 908 1032 </div> 1033 + 1034 + {/* ---------------- Verification request modal ---------------- */} 1035 + {requestModalOpen.value && ( 1036 + <div 1037 + class="modal-backdrop" 1038 + role="dialog" 1039 + aria-modal="true" 1040 + aria-labelledby="icon-access-request-title" 1041 + onClick={(e) => { 1042 + if (e.target === e.currentTarget) { 1043 + requestModalOpen.value = false; 1044 + } 1045 + }} 1046 + > 1047 + <div class="modal-card glass icon-access-modal"> 1048 + <h2 id="icon-access-request-title" class="text-card"> 1049 + {tIcon.requestModal.title} 1050 + </h2> 1051 + <p class="text-body mt-2">{tIcon.requestModal.body}</p> 1052 + <form onSubmit={submitVerificationRequest} class="mt-4"> 1053 + <label class="profile-form-field"> 1054 + <span class="profile-form-label"> 1055 + {tIcon.requestModal.emailLabel}{" "} 1056 + <span class="profile-form-required">*</span> 1057 + </span> 1058 + <input 1059 + type="email" 1060 + required 1061 + autoFocus 1062 + maxLength={320} 1063 + placeholder={tIcon.requestModal.emailPlaceholder} 1064 + value={requestEmail.value} 1065 + onInput={(e) => 1066 + requestEmail.value = 1067 + (e.currentTarget as HTMLInputElement).value} 1068 + class="profile-form-input" 1069 + /> 1070 + </label> 1071 + {requestError.value && ( 1072 + <p class="profile-form-status profile-form-status--error mt-3"> 1073 + {tIcon.requestModal.errorPrefix}: {requestError.value} 1074 + </p> 1075 + )} 1076 + <div class="modal-actions mt-4"> 1077 + <button 1078 + type="submit" 1079 + class="profile-form-button-primary" 1080 + disabled={requestSubmitting.value} 1081 + > 1082 + {requestSubmitting.value 1083 + ? tIcon.requestModal.submitting 1084 + : tIcon.requestModal.submit} 1085 + </button> 1086 + <button 1087 + type="button" 1088 + class="profile-form-button-link" 1089 + onClick={() => (requestModalOpen.value = false)} 1090 + disabled={requestSubmitting.value} 1091 + > 1092 + {tIcon.requestModal.cancel} 1093 + </button> 1094 + </div> 1095 + </form> 1096 + </div> 1097 + </div> 1098 + )} 909 1099 910 1100 <BskyClientPickerModal 911 1101 open={bskyPickerOpen.value}
+3 -2
islands/LinkUrlOverrideModal.tsx
··· 81 81 class="profile-form-input" 82 82 placeholder={labels.placeholder || defaultUrl} 83 83 value={draft.value} 84 - onInput={(e) => 85 - (draft.value = (e.currentTarget as HTMLInputElement).value)} 84 + onInput={( 85 + e, 86 + ) => (draft.value = (e.currentTarget as HTMLInputElement).value)} 86 87 /> 87 88 </label> 88 89 <footer class="modal-footer">
+32 -24
islands/RegistryApiPlayground.tsx
··· 142 142 type="button" 143 143 role="tab" 144 144 aria-selected={kind.value === k} 145 - class={`api-playground-tab ${ 146 - kind.value === k ? "is-active" : "" 147 - }`} 145 + class={`api-playground-tab ${kind.value === k ? "is-active" : ""}`} 148 146 onClick={() => (kind.value = k)} 149 147 > 150 148 {tApi.tabs[k]} ··· 161 159 class="api-playground-input" 162 160 placeholder={tApi.placeholders.profileId} 163 161 value={profileId.value} 164 - onInput={(e) => 165 - (profileId.value = (e.currentTarget as HTMLInputElement).value)} 162 + onInput={( 163 + e, 164 + ) => (profileId.value = 165 + (e.currentTarget as HTMLInputElement).value)} 166 166 onKeyDown={(e) => { 167 167 if (e.key === "Enter") { 168 168 e.preventDefault(); ··· 176 176 {kind.value === "search" && ( 177 177 <div class="api-playground-grid"> 178 178 <label class="api-playground-field"> 179 - <span class="api-playground-label">{tApi.fields.searchQuery}</span> 179 + <span class="api-playground-label"> 180 + {tApi.fields.searchQuery} 181 + </span> 180 182 <input 181 183 type="text" 182 184 class="api-playground-input" 183 185 placeholder={tApi.placeholders.searchQuery} 184 186 value={searchQuery.value} 185 - onInput={(e) => 186 - (searchQuery.value = 187 - (e.currentTarget as HTMLInputElement).value)} 187 + onInput={( 188 + e, 189 + ) => (searchQuery.value = 190 + (e.currentTarget as HTMLInputElement).value)} 188 191 /> 189 192 </label> 190 193 <label class="api-playground-field"> ··· 192 195 <select 193 196 class="api-playground-input" 194 197 value={searchCategory.value} 195 - onChange={(e) => 196 - (searchCategory.value = 197 - (e.currentTarget as HTMLSelectElement).value)} 198 + onChange={( 199 + e, 200 + ) => (searchCategory.value = 201 + (e.currentTarget as HTMLSelectElement).value)} 198 202 > 199 203 <option value="">{tApi.fields.anyCategory}</option> 200 204 {CATEGORIES.map((c) => ( ··· 209 213 <select 210 214 class="api-playground-input" 211 215 value={searchSubcategory.value} 212 - onChange={(e) => 213 - (searchSubcategory.value = 214 - (e.currentTarget as HTMLSelectElement).value)} 216 + onChange={( 217 + e, 218 + ) => (searchSubcategory.value = 219 + (e.currentTarget as HTMLSelectElement).value)} 215 220 > 216 221 <option value="">{tApi.fields.anySubcategory}</option> 217 222 {APP_SUBCATEGORIES.map((s) => ( ··· 226 231 min={1} 227 232 class="api-playground-input" 228 233 value={searchPage.value} 229 - onInput={(e) => 230 - (searchPage.value = 231 - (e.currentTarget as HTMLInputElement).value)} 234 + onInput={( 235 + e, 236 + ) => (searchPage.value = 237 + (e.currentTarget as HTMLInputElement).value)} 232 238 /> 233 239 </label> 234 240 <label class="api-playground-field"> ··· 239 245 max={48} 240 246 class="api-playground-input" 241 247 value={searchPageSize.value} 242 - onInput={(e) => 243 - (searchPageSize.value = 244 - (e.currentTarget as HTMLInputElement).value)} 248 + onInput={( 249 + e, 250 + ) => (searchPageSize.value = 251 + (e.currentTarget as HTMLInputElement).value)} 245 252 /> 246 253 </label> 247 254 </div> ··· 256 263 max={48} 257 264 class="api-playground-input" 258 265 value={featuredLimit.value} 259 - onInput={(e) => 260 - (featuredLimit.value = 261 - (e.currentTarget as HTMLInputElement).value)} 266 + onInput={( 267 + e, 268 + ) => (featuredLimit.value = 269 + (e.currentTarget as HTMLInputElement).value)} 262 270 /> 263 271 </label> 264 272 )}
+14 -4
islands/ReportProfileButton.tsx
··· 126 126 <strong>{copy.sentTitle}</strong> 127 127 </p> 128 128 <p class="modal-body-text">{copy.sentBody}</p> 129 - <div class="report-modal-actions" style={{ marginTop: "1rem" }}> 129 + <div 130 + class="report-modal-actions" 131 + style={{ marginTop: "1rem" }} 132 + > 130 133 <button 131 134 type="button" 132 135 class="profile-form-button-primary" ··· 148 151 name="report-reason" 149 152 value={r} 150 153 checked={reason.value === r} 151 - onChange={() => reason.value = r} 154 + onChange={() => 155 + reason.value = r} 152 156 /> 153 157 {copy.reasons[r]} 154 158 </label> 155 159 ))} 156 160 </fieldset> 157 161 158 - <label class="report-modal-radio" style={{ display: "block" }}> 162 + <label 163 + class="report-modal-radio" 164 + style={{ display: "block" }} 165 + > 159 166 <span style={{ display: "block", marginBottom: "0.4rem" }}> 160 167 {copy.detailsLabel} 161 168 </span> ··· 176 183 </p> 177 184 )} 178 185 179 - <div class="report-modal-actions" style={{ marginTop: "1rem" }}> 186 + <div 187 + class="report-modal-actions" 188 + style={{ marginTop: "1rem" }} 189 + > 180 190 <button 181 191 type="button" 182 192 class="profile-form-button-link"
+22 -22
lexicons/com/atmosphereaccount/registry/profile.json
··· 8 8 "key": "literal:self", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["name", "description", "categories", "createdAt"], 12 - "properties": { 13 - "name": { 14 - "type": "string", 15 - "minLength": 1, 16 - "maxLength": 60, 17 - "maxGraphemes": 60, 18 - "description": "Display name for the project." 19 - }, 20 - "description": { 21 - "type": "string", 22 - "minLength": 1, 23 - "maxLength": 500, 24 - "maxGraphemes": 500, 25 - "description": "Short description of the project." 26 - }, 27 - "mainLink": { 28 - "type": "string", 29 - "format": "uri", 30 - "maxLength": 512, 31 - "description": "Primary destination for the project (the actual app, service, or landing page). The whole profile card on /explore is rendered as a button to this URL. Optional in the lexicon for backward compatibility with records created before mainLink existed; the registry UI requires it for new/updated records." 32 - }, 11 + "required": ["name", "description", "categories", "createdAt"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "minLength": 1, 16 + "maxLength": 60, 17 + "maxGraphemes": 60, 18 + "description": "Display name for the project." 19 + }, 20 + "description": { 21 + "type": "string", 22 + "minLength": 1, 23 + "maxLength": 500, 24 + "maxGraphemes": 500, 25 + "description": "Short description of the project." 26 + }, 27 + "mainLink": { 28 + "type": "string", 29 + "format": "uri", 30 + "maxLength": 512, 31 + "description": "Primary destination for the project (the actual app, service, or landing page). The whole profile card on /explore is rendered as a button to this URL. Optional in the lexicon for backward compatibility with records created before mainLink existed; the registry UI requires it for new/updated records." 32 + }, 33 33 "avatar": { 34 34 "type": "blob", 35 35 "accept": ["image/png", "image/jpeg", "image/webp"],
+41
lib/db.ts
··· 82 82 icon_reviewed_by TEXT, 83 83 icon_reviewed_at INTEGER, 84 84 icon_rejected_reason TEXT, 85 + icon_access_status TEXT, 86 + icon_access_email TEXT, 87 + icon_access_requested_at INTEGER, 88 + icon_access_reviewed_at INTEGER, 89 + icon_access_reviewed_by TEXT, 90 + icon_access_denied_reason TEXT, 85 91 takedown_status TEXT, 86 92 takedown_reason TEXT, 87 93 takedown_by TEXT, ··· 180 186 * `IS NULL` predicates. Plain index covers both directions. 181 187 */ 182 188 `CREATE INDEX IF NOT EXISTS profile_takedown ON profile(takedown_status)`, 189 + /** 190 + * Hot-path index for the admin "Icon access requests" queue. Only a 191 + * handful of rows are non-NULL at any time so the index stays cheap. 192 + */ 193 + `CREATE INDEX IF NOT EXISTS profile_icon_access ON profile(icon_access_status)`, 183 194 ]; 184 195 185 196 /** ··· 241 252 table: "profile", 242 253 column: "icon_rejected_reason", 243 254 ddl: "ALTER TABLE profile ADD COLUMN icon_rejected_reason TEXT", 255 + }, 256 + { 257 + table: "profile", 258 + column: "icon_access_status", 259 + ddl: "ALTER TABLE profile ADD COLUMN icon_access_status TEXT", 260 + }, 261 + { 262 + table: "profile", 263 + column: "icon_access_email", 264 + ddl: "ALTER TABLE profile ADD COLUMN icon_access_email TEXT", 265 + }, 266 + { 267 + table: "profile", 268 + column: "icon_access_requested_at", 269 + ddl: "ALTER TABLE profile ADD COLUMN icon_access_requested_at INTEGER", 270 + }, 271 + { 272 + table: "profile", 273 + column: "icon_access_reviewed_at", 274 + ddl: "ALTER TABLE profile ADD COLUMN icon_access_reviewed_at INTEGER", 275 + }, 276 + { 277 + table: "profile", 278 + column: "icon_access_reviewed_by", 279 + ddl: "ALTER TABLE profile ADD COLUMN icon_access_reviewed_by TEXT", 280 + }, 281 + { 282 + table: "profile", 283 + column: "icon_access_denied_reason", 284 + ddl: "ALTER TABLE profile ADD COLUMN icon_access_denied_reason TEXT", 244 285 }, 245 286 { 246 287 table: "profile",
+4 -1
lib/rate-limit.ts
··· 53 53 } else { 54 54 const elapsed = now - b.last; 55 55 if (elapsed > 0) { 56 - b.tokens = Math.min(CAPACITY, b.tokens + (elapsed / REFILL_MS) * CAPACITY); 56 + b.tokens = Math.min( 57 + CAPACITY, 58 + b.tokens + (elapsed / REFILL_MS) * CAPACITY, 59 + ); 57 60 b.last = now; 58 61 } 59 62 }
+245 -59
lib/registry.ts
··· 18 18 export type IconStatus = "pending" | "approved" | "rejected"; 19 19 20 20 /** 21 + * Per-project verification gate for SVG icon uploads. The icon section 22 + * in the profile form (and the PUT /api/registry/profile API) refuses 23 + * any icon write unless the project is `granted`. 24 + * 25 + * - `null` — never requested. Form shows "Request Verification". 26 + * - `requested` — user submitted a request with a contact email, 27 + * awaiting admin review. Form shows "Pending review". 28 + * - `granted` — admin approved; icon uploads accepted. Sanitiser 29 + * still runs server-side. 30 + * - `denied` — admin denied (or revoked previously-granted access). 31 + * Form shows the appeal email; only an admin can 32 + * re-open. 33 + */ 34 + export type IconAccessStatus = "requested" | "granted" | "denied"; 35 + 36 + /** 21 37 * Moderation state for the *whole profile*. Distinct from icon status — 22 38 * a takedown removes the profile from public reads (search, /explore, 23 39 * /api/registry/*) regardless of icon state. The user's PDS record is ··· 53 69 iconReviewedBy: string | null; 54 70 iconReviewedAt: number | null; 55 71 iconRejectedReason: string | null; 72 + /** Per-project SVG-upload verification state. */ 73 + iconAccessStatus: IconAccessStatus | null; 74 + /** Contact email captured at request time (admin-only sees this). */ 75 + iconAccessEmail: string | null; 76 + iconAccessRequestedAt: number | null; 77 + iconAccessReviewedAt: number | null; 78 + iconAccessReviewedBy: string | null; 79 + /** Optional reason supplied by admin when denying access. */ 80 + iconAccessDeniedReason: string | null; 56 81 /** Profile-level takedown state. `null` means live. */ 57 82 takedownStatus: TakedownStatus | null; 58 83 takedownReason: string | null; ··· 87 112 icon_reviewed_by: string | null; 88 113 icon_reviewed_at: number | null; 89 114 icon_rejected_reason: string | null; 115 + icon_access_status: string | null; 116 + icon_access_email: string | null; 117 + icon_access_requested_at: number | null; 118 + icon_access_reviewed_at: number | null; 119 + icon_access_reviewed_by: string | null; 120 + icon_access_denied_reason: string | null; 90 121 takedown_status: string | null; 91 122 takedown_reason: string | null; 92 123 takedown_by: string | null; ··· 134 165 135 166 function normalizeIconStatus(v: string | null): IconStatus | null { 136 167 if (v === "pending" || v === "approved" || v === "rejected") return v; 168 + return null; 169 + } 170 + 171 + function normalizeIconAccessStatus( 172 + v: string | null, 173 + ): IconAccessStatus | null { 174 + if (v === "requested" || v === "granted" || v === "denied") return v; 137 175 return null; 138 176 } 139 177 ··· 161 199 ? Number(r.icon_reviewed_at) 162 200 : null, 163 201 iconRejectedReason: r.icon_rejected_reason, 202 + iconAccessStatus: normalizeIconAccessStatus(r.icon_access_status), 203 + iconAccessEmail: r.icon_access_email, 204 + iconAccessRequestedAt: r.icon_access_requested_at != null 205 + ? Number(r.icon_access_requested_at) 206 + : null, 207 + iconAccessReviewedAt: r.icon_access_reviewed_at != null 208 + ? Number(r.icon_access_reviewed_at) 209 + : null, 210 + iconAccessReviewedBy: r.icon_access_reviewed_by, 211 + iconAccessDeniedReason: r.icon_access_denied_reason, 164 212 takedownStatus: normalizeTakedownStatus(r.takedown_status), 165 213 takedownReason: r.takedown_reason, 166 214 takedownBy: r.takedown_by, ··· 224 272 throw new Error("upsertProfile: categories[] is required and non-empty"); 225 273 } 226 274 /** 227 - * Initial icon_status for the INSERT branch (and for new icons on 228 - * existing rows): `pending` if there's an icon, NULL otherwise. 229 - * The ON CONFLICT branch below uses CASE statements to preserve any 230 - * existing approval state when the CID hasn't changed. 275 + * Initial icon_status for the INSERT branch only (i.e. brand-new 276 + * profile rows): always NULL because per-project verification can't 277 + * possibly have happened yet — the user has to publish the profile, 278 + * then request access. The ON CONFLICT branch below decides the 279 + * status by reading the existing row's icon_access_status. 231 280 */ 232 - const initialIconStatus = input.iconCid ? "pending" : null; 281 + const initialIconStatus: string | null = null; 233 282 await withDb(async (c) => { 234 283 await c.execute({ 235 284 sql: ` ··· 238 287 categories, subcategories, links, 239 288 avatar_cid, avatar_mime, icon_cid, icon_mime, icon_status, 240 289 icon_reviewed_by, icon_reviewed_at, icon_rejected_reason, 290 + icon_access_status, icon_access_email, icon_access_requested_at, 291 + icon_access_reviewed_at, icon_access_reviewed_by, 292 + icon_access_denied_reason, 241 293 takedown_status, takedown_reason, takedown_by, takedown_at, 242 294 pds_url, record_cid, record_rev, created_at, indexed_at 243 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?) 295 + ) VALUES ( 296 + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 297 + NULL, NULL, NULL, 298 + NULL, NULL, NULL, NULL, NULL, NULL, 299 + NULL, NULL, NULL, NULL, 300 + ?, ?, ?, ?, ? 301 + ) 244 302 ON CONFLICT(did) DO UPDATE SET 245 303 handle=excluded.handle, 246 304 name=excluded.name, ··· 253 311 avatar_mime=excluded.avatar_mime, 254 312 icon_cid=excluded.icon_cid, 255 313 icon_mime=excluded.icon_mime, 314 + /** 315 + * Per-project verification gate. 316 + * - No icon → NULL (nothing to review). 317 + * - Same icon as before → preserve existing status. 318 + * - New icon AND project verified → auto-approve (the 319 + * gate is at the project level, not per-icon). 320 + * - New icon AND project NOT verified → 'pending'. The 321 + * PUT API refuses unverified uploads server-side, so 322 + * this branch only fires for firehose writes from a 323 + * PDS path that bypasses our gate; serving still 324 + * refuses to render it because access != 'granted'. 325 + */ 256 326 icon_status = CASE 257 327 WHEN excluded.icon_cid IS NULL THEN NULL 258 328 WHEN profile.icon_cid IS NOT NULL AND profile.icon_cid = excluded.icon_cid THEN profile.icon_status 329 + WHEN profile.icon_access_status = 'granted' THEN 'approved' 259 330 ELSE 'pending' 260 331 END, 261 332 icon_reviewed_by = CASE ··· 273 344 WHEN profile.icon_cid IS NOT NULL AND profile.icon_cid = excluded.icon_cid THEN profile.icon_rejected_reason 274 345 ELSE NULL 275 346 END, 347 + /** 348 + * Per-project verification state is admin-managed and must 349 + * survive any firehose-driven re-upsert. Same shape as the 350 + * takedown columns: only mutated by the dedicated grant / 351 + * deny / request helpers. 352 + */ 353 + icon_access_status = profile.icon_access_status, 354 + icon_access_email = profile.icon_access_email, 355 + icon_access_requested_at = profile.icon_access_requested_at, 356 + icon_access_reviewed_at = profile.icon_access_reviewed_at, 357 + icon_access_reviewed_by = profile.icon_access_reviewed_by, 358 + icon_access_denied_reason = profile.icon_access_denied_reason, 276 359 /** 277 360 * Takedown columns are admin-only state and must survive any 278 361 * firehose-driven re-upsert. We never overwrite them from the ··· 315 398 } 316 399 317 400 /* -------------------------------------------------------------------------- * 318 - * Icon moderation * 401 + * Icon-access verification (per-project) * 319 402 * -------------------------------------------------------------------------- */ 320 403 321 - export interface PendingIconRow { 404 + export interface IconAccessRequestRow { 322 405 did: string; 323 406 handle: string; 324 407 name: string; 325 - iconCid: string; 326 - iconMime: string; 327 - indexedAt: number; 408 + email: string; 409 + requestedAt: number; 328 410 } 329 411 330 - /** Profiles awaiting SVG icon approval, oldest first (FIFO review queue). */ 331 - export async function listPendingIcons(): Promise<PendingIconRow[]> { 332 - return await withDb(async (c) => { 333 - const r = await c.execute(` 334 - SELECT did, handle, name, icon_cid, icon_mime, indexed_at 335 - FROM profile 336 - WHERE icon_status = 'pending' AND icon_cid IS NOT NULL 337 - ORDER BY indexed_at ASC 338 - `); 339 - return r.rows.map((row) => { 340 - const x = row as unknown as { 341 - did: string; 342 - handle: string; 343 - name: string; 344 - icon_cid: string; 345 - icon_mime: string; 346 - indexed_at: number; 347 - }; 348 - return { 349 - did: x.did, 350 - handle: x.handle, 351 - name: x.name, 352 - iconCid: x.icon_cid, 353 - iconMime: x.icon_mime, 354 - indexedAt: Number(x.indexed_at), 355 - }; 356 - }); 357 - }); 412 + export interface GrantedIconAccessRow { 413 + did: string; 414 + handle: string; 415 + name: string; 416 + email: string | null; 417 + reviewedAt: number; 418 + reviewedBy: string; 358 419 } 359 420 360 - export async function countPendingIcons(): Promise<number> { 421 + /** 422 + * Open a verification request for the SVG-icon upload feature. The 423 + * caller must already own a published profile (DID matches the row). 424 + * 425 + * Allowed transitions: 426 + * - `null` or `denied` → `requested` 427 + * 428 + * Returns `false` if the row is currently `requested` or `granted` (no 429 + * change required) or the row doesn't exist. 430 + */ 431 + export async function requestIconAccess( 432 + did: string, 433 + email: string, 434 + ): Promise<boolean> { 435 + const cleanEmail = email.trim().slice(0, 320); 361 436 return await withDb(async (c) => { 362 - const r = await c.execute( 363 - `SELECT COUNT(*) AS n FROM profile WHERE icon_status = 'pending' AND icon_cid IS NOT NULL`, 364 - ); 365 - return Number((r.rows[0] as Record<string, unknown>).n ?? 0); 437 + const r = await c.execute({ 438 + sql: ` 439 + UPDATE profile SET 440 + icon_access_status = 'requested', 441 + icon_access_email = ?, 442 + icon_access_requested_at = ?, 443 + icon_access_reviewed_at = NULL, 444 + icon_access_reviewed_by = NULL, 445 + icon_access_denied_reason = NULL 446 + WHERE did = ? 447 + AND (icon_access_status IS NULL OR icon_access_status = 'denied') 448 + `, 449 + args: [cleanEmail, Date.now(), did], 450 + }); 451 + return Number(r.rowsAffected ?? 0) > 0; 366 452 }); 367 453 } 368 454 369 - export async function approveIcon( 455 + /** Grant icon-upload access. Allowed from any state. */ 456 + export async function grantIconAccess( 370 457 did: string, 371 458 reviewer: string, 372 459 ): Promise<void> { ··· 374 461 await c.execute({ 375 462 sql: ` 376 463 UPDATE profile SET 377 - icon_status = 'approved', 378 - icon_reviewed_by = ?, 379 - icon_reviewed_at = ?, 380 - icon_rejected_reason = NULL 381 - WHERE did = ? AND icon_cid IS NOT NULL 464 + icon_access_status = 'granted', 465 + icon_access_reviewed_at = ?, 466 + icon_access_reviewed_by = ?, 467 + icon_access_denied_reason = NULL, 468 + /** 469 + * If an icon is already on file (uploaded back when the 470 + * project was previously granted, then revoked, then granted 471 + * again), promote it straight to approved so the developer 472 + * API picks up serving without a republish from the user. 473 + */ 474 + icon_status = CASE 475 + WHEN icon_cid IS NOT NULL THEN 'approved' 476 + ELSE icon_status 477 + END 478 + WHERE did = ? 382 479 `, 383 - args: [reviewer, Date.now(), did], 480 + args: [Date.now(), reviewer, did], 384 481 }); 385 482 }); 386 483 } 387 484 388 - export async function rejectIcon( 485 + /** 486 + * Deny icon-upload access. Used both for the initial denial and to 487 + * revoke a previously-granted access (the row stays in 'denied' until 488 + * an admin manually re-opens via grantIconAccess). 489 + */ 490 + export async function denyIconAccess( 389 491 did: string, 390 492 reviewer: string, 391 - reason: string, 493 + reason?: string, 392 494 ): Promise<void> { 495 + const cleanReason = reason ? reason.trim().slice(0, 500) : null; 393 496 await withDb(async (c) => { 394 497 await c.execute({ 395 498 sql: ` 396 499 UPDATE profile SET 397 - icon_status = 'rejected', 398 - icon_reviewed_by = ?, 399 - icon_reviewed_at = ?, 400 - icon_rejected_reason = ? 401 - WHERE did = ? AND icon_cid IS NOT NULL 500 + icon_access_status = 'denied', 501 + icon_access_reviewed_at = ?, 502 + icon_access_reviewed_by = ?, 503 + icon_access_denied_reason = ?, 504 + /** 505 + * Stop serving any existing icon the moment access is 506 + * denied/revoked. The blob stays on the user's PDS untouched 507 + * (we don't have authority to delete it), but our serve route 508 + * checks icon_status alongside the access gate so flipping 509 + * this is enough to take it offline immediately. 510 + */ 511 + icon_status = CASE 512 + WHEN icon_cid IS NOT NULL THEN 'rejected' 513 + ELSE icon_status 514 + END 515 + WHERE did = ? 402 516 `, 403 - args: [reviewer, Date.now(), reason.slice(0, 500), did], 517 + args: [Date.now(), reviewer, cleanReason, did], 518 + }); 519 + }); 520 + } 521 + 522 + /** Pending verification requests, oldest first (FIFO review queue). */ 523 + export async function listPendingIconAccess(): Promise<IconAccessRequestRow[]> { 524 + return await withDb(async (c) => { 525 + const r = await c.execute(` 526 + SELECT did, handle, name, icon_access_email, icon_access_requested_at 527 + FROM profile 528 + WHERE icon_access_status = 'requested' 529 + ORDER BY icon_access_requested_at ASC 530 + `); 531 + return r.rows.map((row) => { 532 + const x = row as unknown as { 533 + did: string; 534 + handle: string; 535 + name: string; 536 + icon_access_email: string | null; 537 + icon_access_requested_at: number | null; 538 + }; 539 + return { 540 + did: x.did, 541 + handle: x.handle, 542 + name: x.name, 543 + email: x.icon_access_email ?? "", 544 + requestedAt: x.icon_access_requested_at != null 545 + ? Number(x.icon_access_requested_at) 546 + : 0, 547 + }; 548 + }); 549 + }); 550 + } 551 + 552 + export async function countPendingIconAccess(): Promise<number> { 553 + return await withDb(async (c) => { 554 + const r = await c.execute( 555 + `SELECT COUNT(*) AS n FROM profile WHERE icon_access_status = 'requested'`, 556 + ); 557 + return Number((r.rows[0] as Record<string, unknown>).n ?? 0); 558 + }); 559 + } 560 + 561 + /** Currently-verified projects, most-recently-granted first. */ 562 + export async function listGrantedIconAccess(): Promise<GrantedIconAccessRow[]> { 563 + return await withDb(async (c) => { 564 + const r = await c.execute(` 565 + SELECT did, handle, name, icon_access_email, 566 + icon_access_reviewed_at, icon_access_reviewed_by 567 + FROM profile 568 + WHERE icon_access_status = 'granted' 569 + ORDER BY icon_access_reviewed_at DESC 570 + `); 571 + return r.rows.map((row) => { 572 + const x = row as unknown as { 573 + did: string; 574 + handle: string; 575 + name: string; 576 + icon_access_email: string | null; 577 + icon_access_reviewed_at: number | null; 578 + icon_access_reviewed_by: string | null; 579 + }; 580 + return { 581 + did: x.did, 582 + handle: x.handle, 583 + name: x.name, 584 + email: x.icon_access_email, 585 + reviewedAt: x.icon_access_reviewed_at != null 586 + ? Number(x.icon_access_reviewed_at) 587 + : 0, 588 + reviewedBy: x.icon_access_reviewed_by ?? "", 589 + }; 404 590 }); 405 591 }); 406 592 }
+158
routes/admin/icon-access.tsx
··· 1 + /** 2 + * Admin: SVG-icon access request queue. Lists projects whose owner 3 + * has submitted a verification request, plus a roster of currently 4 + * granted projects with a Revoke action. 5 + */ 6 + import { define } from "../../utils.ts"; 7 + import Nav from "../../components/Nav.tsx"; 8 + import GlassClouds from "../../components/GlassClouds.tsx"; 9 + import Footer from "../../components/Footer.tsx"; 10 + import AdminIconAccessRow from "../../islands/AdminIconAccessRow.tsx"; 11 + import AdminIconAccessRevoke from "../../islands/AdminIconAccessRevoke.tsx"; 12 + import { getMessages } from "../../i18n/mod.ts"; 13 + import type { Locale } from "../../i18n/mod.ts"; 14 + import { 15 + type GrantedIconAccessRow, 16 + type IconAccessRequestRow, 17 + listGrantedIconAccess, 18 + listPendingIconAccess, 19 + } from "../../lib/registry.ts"; 20 + 21 + export const handler = define.handlers({ 22 + async GET(ctx) { 23 + const [pending, granted] = await Promise.all([ 24 + listPendingIconAccess().catch(() => [] as IconAccessRequestRow[]), 25 + listGrantedIconAccess().catch(() => [] as GrantedIconAccessRow[]), 26 + ]); 27 + return ctx.render( 28 + <Page 29 + user={ctx.state.user!} 30 + pending={pending} 31 + granted={granted} 32 + locale={ctx.state.locale} 33 + />, 34 + ); 35 + }, 36 + }); 37 + 38 + interface PageProps { 39 + user: { did: string; handle: string }; 40 + pending: IconAccessRequestRow[]; 41 + granted: GrantedIconAccessRow[]; 42 + locale: Locale; 43 + } 44 + 45 + function Page({ user, pending, granted, locale }: PageProps) { 46 + const t = getMessages(locale).admin; 47 + const ti = t.iconAccess; 48 + return ( 49 + <div id="page-top"> 50 + <GlassClouds /> 51 + <div class="content-layer"> 52 + <Nav 53 + account={{ 54 + user: { did: user.did, handle: user.handle }, 55 + avatarUrl: "/api/me/avatar", 56 + publicProfileHandle: null, 57 + }} 58 + /> 59 + <section class="admin-section"> 60 + <div class="container" style={{ maxWidth: "920px" }}> 61 + <p> 62 + <a href="/admin" class="text-link-button"> 63 + ← {t.backToOverview} 64 + </a> 65 + </p> 66 + <header class="admin-header" style={{ marginTop: "0.75rem" }}> 67 + <h1 class="text-section">{ti.headline}</h1> 68 + <p class="text-body mt-2">{ti.subhead}</p> 69 + </header> 70 + 71 + <h2 class="text-card mt-6">{ti.pendingHeading}</h2> 72 + {pending.length === 0 73 + ? <p class="text-body admin-empty">{ti.emptyPending}</p> 74 + : ( 75 + <div class="admin-icon-list"> 76 + {pending.map((row) => ( 77 + <AdminIconAccessRow 78 + key={row.did} 79 + did={row.did} 80 + handle={row.handle} 81 + name={row.name} 82 + email={row.email} 83 + requestedAt={row.requestedAt} 84 + copy={{ 85 + grant: ti.grant, 86 + deny: ti.deny, 87 + denyPrompt: ti.denyPrompt, 88 + grantedLabel: ti.markedGranted, 89 + deniedLabel: ti.markedDenied, 90 + requestedAtLabel: ti.requestedAtLabel, 91 + emailLabel: ti.emailLabel, 92 + viewProfile: ti.viewProfile, 93 + error: t.errorPrefix, 94 + }} 95 + /> 96 + ))} 97 + </div> 98 + )} 99 + 100 + <h2 class="text-card mt-6">{ti.grantedHeading}</h2> 101 + {granted.length === 0 102 + ? <p class="text-body admin-empty">{ti.emptyGranted}</p> 103 + : ( 104 + <div class="admin-icon-list"> 105 + {granted.map((row) => { 106 + const reviewed = new Date(row.reviewedAt).toISOString() 107 + .slice(0, 10); 108 + return ( 109 + <div class="admin-icon-row" key={row.did}> 110 + <div class="admin-icon-row-meta"> 111 + <p class="admin-icon-row-name"> 112 + <strong>{row.name}</strong> 113 + <span class="admin-icon-row-handle"> 114 + <a 115 + href={`/explore/${ 116 + encodeURIComponent(row.handle) 117 + }`} 118 + target="_blank" 119 + rel="noopener noreferrer" 120 + class="text-link-button" 121 + > 122 + @{row.handle} ↗ 123 + </a> 124 + </span> 125 + </p> 126 + <p class="admin-icon-row-did"> 127 + <code>{row.did}</code> 128 + </p> 129 + {row.email && ( 130 + <p class="admin-icon-row-uploaded"> 131 + <strong>{ti.emailLabel}:</strong> {row.email} 132 + </p> 133 + )} 134 + <p class="admin-icon-row-uploaded"> 135 + {ti.grantedAtLabel} {reviewed} 136 + </p> 137 + </div> 138 + <div class="admin-icon-row-actions"> 139 + <AdminIconAccessRevoke 140 + did={row.did} 141 + label={ti.revoke} 142 + promptText={ti.denyPrompt} 143 + doneLabel={ti.markedDenied} 144 + errorPrefix={t.errorPrefix} 145 + /> 146 + </div> 147 + </div> 148 + ); 149 + })} 150 + </div> 151 + )} 152 + </div> 153 + </section> 154 + <Footer variant="compact" /> 155 + </div> 156 + </div> 157 + ); 158 + }
-100
routes/admin/icons.tsx
··· 1 - /** 2 - * Admin: pending SVG icon review queue. Server-renders one row per 3 - * pending project + an `AdminIconReview` island that owns the 4 - * approve / reject buttons. 5 - */ 6 - import { define } from "../../utils.ts"; 7 - import Nav from "../../components/Nav.tsx"; 8 - import GlassClouds from "../../components/GlassClouds.tsx"; 9 - import Footer from "../../components/Footer.tsx"; 10 - import AdminIconReview from "../../islands/AdminIconReview.tsx"; 11 - import { getMessages } from "../../i18n/mod.ts"; 12 - import type { Locale } from "../../i18n/mod.ts"; 13 - import { listPendingIcons, type PendingIconRow } from "../../lib/registry.ts"; 14 - 15 - export const handler = define.handlers({ 16 - async GET(ctx) { 17 - const queue = await listPendingIcons().catch(() => [] as PendingIconRow[]); 18 - return ctx.render( 19 - <AdminIconsPage 20 - user={ctx.state.user!} 21 - queue={queue} 22 - locale={ctx.state.locale} 23 - />, 24 - ); 25 - }, 26 - }); 27 - 28 - interface PageProps { 29 - user: { did: string; handle: string }; 30 - queue: PendingIconRow[]; 31 - locale: Locale; 32 - } 33 - 34 - function AdminIconsPage({ user, queue, locale }: PageProps) { 35 - const t = getMessages(locale).admin; 36 - return ( 37 - <div id="page-top"> 38 - <GlassClouds /> 39 - <div class="content-layer"> 40 - <Nav 41 - account={{ 42 - user: { did: user.did, handle: user.handle }, 43 - avatarUrl: "/api/me/avatar", 44 - publicProfileHandle: null, 45 - }} 46 - /> 47 - <section class="admin-section"> 48 - <div class="container" style={{ maxWidth: "920px" }}> 49 - <p> 50 - <a href="/admin" class="text-link-button"> 51 - ← {t.backToOverview} 52 - </a> 53 - </p> 54 - <header class="admin-header" style={{ marginTop: "0.75rem" }}> 55 - <h1 class="text-section">{t.icons.headline}</h1> 56 - <p class="text-body mt-2">{t.icons.subhead}</p> 57 - </header> 58 - 59 - {queue.length === 0 60 - ? ( 61 - <p class="text-body admin-empty"> 62 - {t.icons.empty} 63 - </p> 64 - ) 65 - : ( 66 - <div class="admin-icon-list"> 67 - {queue.map((row) => ( 68 - <AdminIconReview 69 - key={row.did} 70 - did={row.did} 71 - handle={row.handle} 72 - name={row.name} 73 - previewUrl={`/api/admin/icons/${ 74 - encodeURIComponent(row.did) 75 - }/preview`} 76 - uploadedAt={row.indexedAt} 77 - copy={{ 78 - approve: t.icons.approve, 79 - reject: t.icons.reject, 80 - rejectReasonPlaceholder: 81 - t.icons.rejectReasonPlaceholder, 82 - confirmReject: t.icons.confirmReject, 83 - submit: t.icons.submitReject, 84 - cancel: t.icons.cancel, 85 - pending: t.statusBadge.pending, 86 - approved: t.icons.markedApproved, 87 - rejected: t.icons.markedRejected, 88 - error: t.errorPrefix, 89 - }} 90 - /> 91 - ))} 92 - </div> 93 - )} 94 - </div> 95 - </section> 96 - <Footer variant="compact" /> 97 - </div> 98 - </div> 99 - ); 100 - }
+10 -10
routes/admin/index.tsx
··· 9 9 import { getMessages } from "../../i18n/mod.ts"; 10 10 import type { Locale } from "../../i18n/mod.ts"; 11 11 import { 12 - countPendingIcons, 12 + countPendingIconAccess, 13 13 countTakenDownProfiles, 14 14 } from "../../lib/registry.ts"; 15 15 import { countOpenReports } from "../../lib/reports.ts"; 16 16 17 17 export const handler = define.handlers({ 18 18 async GET(ctx) { 19 - const [pendingIcons, openReports, takenDown] = await Promise.all([ 20 - countPendingIcons().catch(() => 0), 19 + const [iconAccessRequests, openReports, takenDown] = await Promise.all([ 20 + countPendingIconAccess().catch(() => 0), 21 21 countOpenReports().catch(() => 0), 22 22 countTakenDownProfiles().catch(() => 0), 23 23 ]); 24 24 return ctx.render( 25 25 <AdminHome 26 26 user={ctx.state.user!} 27 - pendingIcons={pendingIcons} 27 + iconAccessRequests={iconAccessRequests} 28 28 openReports={openReports} 29 29 takenDown={takenDown} 30 30 locale={ctx.state.locale} ··· 35 35 36 36 interface AdminHomeProps { 37 37 user: { did: string; handle: string }; 38 - pendingIcons: number; 38 + iconAccessRequests: number; 39 39 openReports: number; 40 40 takenDown: number; 41 41 locale: Locale; 42 42 } 43 43 44 44 function AdminHome( 45 - { user, pendingIcons, openReports, takenDown, locale }: AdminHomeProps, 45 + { user, iconAccessRequests, openReports, takenDown, locale }: AdminHomeProps, 46 46 ) { 47 47 const t = getMessages(locale).admin; 48 48 return ( ··· 64 64 </header> 65 65 66 66 <div class="admin-grid"> 67 - <a href="/admin/icons" class="admin-card"> 68 - <p class="admin-card-count">{pendingIcons}</p> 69 - <h2 class="admin-card-title">{t.overview.iconsTitle}</h2> 70 - <p class="admin-card-body">{t.overview.iconsBody}</p> 67 + <a href="/admin/icon-access" class="admin-card"> 68 + <p class="admin-card-count">{iconAccessRequests}</p> 69 + <h2 class="admin-card-title">{t.overview.iconAccessTitle}</h2> 70 + <p class="admin-card-body">{t.overview.iconAccessBody}</p> 71 71 </a> 72 72 <a href="/admin/reports" class="admin-card"> 73 73 <p class="admin-card-count">{openReports}</p>
+51
routes/api/admin/icon-access/[did]/deny.ts
··· 1 + /** 2 + * Admin: deny (or revoke) SVG-icon upload access for a project. 3 + * 4 + * POST /api/admin/icon-access/:did/deny { reason? } 5 + * 6 + * Used both for initial denials of a `requested` project and to revoke 7 + * a previously-granted project. The row stays in `denied` until an 8 + * admin manually grants again — the user is shown the appeal email and 9 + * cannot self-re-request. 10 + */ 11 + import { define } from "../../../../../utils.ts"; 12 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 13 + import { denyIconAccess } from "../../../../../lib/registry.ts"; 14 + 15 + interface DenyPayload { 16 + reason?: unknown; 17 + } 18 + 19 + export const handler = define.handlers({ 20 + async POST(ctx) { 21 + const gate = requireAdminApi(ctx); 22 + if (!gate.ok) return gate.response; 23 + 24 + const did = decodeURIComponent(ctx.params.did); 25 + if (!did.startsWith("did:")) return jsonError(400, "invalid_did"); 26 + 27 + const body = await ctx.req.json().catch(() => null) as DenyPayload | null; 28 + const reason = typeof body?.reason === "string" ? body.reason : undefined; 29 + 30 + try { 31 + await denyIconAccess(did, gate.did, reason); 32 + } catch (err) { 33 + const m = err instanceof Error ? err.message : String(err); 34 + return jsonError(500, "deny_failed", m); 35 + } 36 + return new Response(JSON.stringify({ ok: true }), { 37 + status: 200, 38 + headers: { "content-type": "application/json; charset=utf-8" }, 39 + }); 40 + }, 41 + }); 42 + 43 + function jsonError(status: number, code: string, detail?: string): Response { 44 + return new Response( 45 + JSON.stringify(detail ? { error: code, detail } : { error: code }), 46 + { 47 + status, 48 + headers: { "content-type": "application/json; charset=utf-8" }, 49 + }, 50 + ); 51 + }
+9 -10
routes/api/admin/icons/[did]/approve.ts routes/api/admin/icon-access/[did]/grant.ts
··· 1 1 /** 2 - * Admin: approve a pending SVG icon for a project. 2 + * Admin: grant SVG-icon upload access to a project. 3 3 * 4 - * POST /api/admin/icons/:did/approve 4 + * POST /api/admin/icon-access/:did/grant 5 5 * 6 - * On success, /api/registry/icon/:did starts serving the bytes and 7 - * /api/registry/profile/:id begins emitting `iconUrl` for the project. 6 + * Idempotent — re-granting a project that's already granted just 7 + * refreshes the reviewer/timestamp. 8 8 */ 9 9 import { define } from "../../../../../utils.ts"; 10 10 import { requireAdminApi } from "../../../../../lib/admin.ts"; 11 - import { approveIcon } from "../../../../../lib/registry.ts"; 11 + import { grantIconAccess } from "../../../../../lib/registry.ts"; 12 12 13 13 export const handler = define.handlers({ 14 14 async POST(ctx) { ··· 16 16 if (!gate.ok) return gate.response; 17 17 18 18 const did = decodeURIComponent(ctx.params.did); 19 - if (!did.startsWith("did:")) { 20 - return jsonError(400, "invalid_did"); 21 - } 19 + if (!did.startsWith("did:")) return jsonError(400, "invalid_did"); 20 + 22 21 try { 23 - await approveIcon(did, gate.did); 22 + await grantIconAccess(did, gate.did); 24 23 } catch (err) { 25 24 const m = err instanceof Error ? err.message : String(err); 26 - return jsonError(500, "approve_failed", m); 25 + return jsonError(500, "grant_failed", m); 27 26 } 28 27 return new Response(JSON.stringify({ ok: true }), { 29 28 status: 200,
-60
routes/api/admin/icons/[did]/preview.ts
··· 1 - /** 2 - * Admin-only proxy that serves a project's SVG icon regardless of 3 - * approval state — used by the review screen so admins can see what 4 - * they're approving / rejecting. 5 - * 6 - * GET /api/admin/icons/:did/preview 7 - * 8 - * Same hardening headers as the public icon route (`Content-Security- 9 - * Policy: default-src 'none'; …`, `nosniff`, inline disposition) so 10 - * even an as-yet-unsanitised SVG can't run scripts in the admin's 11 - * browser. 12 - */ 13 - import { define } from "../../../../../utils.ts"; 14 - import { requireAdminApi } from "../../../../../lib/admin.ts"; 15 - import { getProfileByDid } from "../../../../../lib/registry.ts"; 16 - import { fetchBlobPublic } from "../../../../../lib/pds.ts"; 17 - 18 - export const handler = define.handlers({ 19 - async GET(ctx) { 20 - const gate = requireAdminApi(ctx); 21 - if (!gate.ok) return gate.response; 22 - 23 - const did = decodeURIComponent(ctx.params.did); 24 - // Include taken-down rows so admins can still inspect an icon that 25 - // was attached to a profile they later took down (useful when 26 - // restoring or re-evaluating). 27 - const profile = await getProfileByDid(did, { includeTakenDown: true }) 28 - .catch(() => null); 29 - if (!profile || !profile.iconCid) { 30 - return new Response("not found", { status: 404 }); 31 - } 32 - try { 33 - const upstream = await fetchBlobPublic( 34 - profile.pdsUrl, 35 - did, 36 - profile.iconCid, 37 - ); 38 - if (!upstream.ok) { 39 - return new Response("not found", { status: 404 }); 40 - } 41 - const headers = new Headers(); 42 - headers.set("content-type", "image/svg+xml; charset=utf-8"); 43 - headers.set("x-content-type-options", "nosniff"); 44 - headers.set( 45 - "content-security-policy", 46 - "default-src 'none'; style-src 'unsafe-inline'; img-src data:", 47 - ); 48 - headers.set( 49 - "content-disposition", 50 - 'inline; filename="atmosphere-icon-preview.svg"', 51 - ); 52 - // Admin previews should never be cached by browsers / CDNs. 53 - headers.set("cache-control", "private, no-store"); 54 - return new Response(upstream.body, { status: 200, headers }); 55 - } catch (err) { 56 - console.warn("admin icon preview error:", err); 57 - return new Response("upstream error", { status: 502 }); 58 - } 59 - }, 60 - });
-49
routes/api/admin/icons/[did]/reject.ts
··· 1 - /** 2 - * Admin: reject a pending SVG icon for a project. 3 - * 4 - * POST /api/admin/icons/:did/reject { reason: string } 5 - * 6 - * The reason is shown to the project owner on /explore/manage so they 7 - * know why their icon isn't appearing in the developer API. 8 - */ 9 - import { define } from "../../../../../utils.ts"; 10 - import { requireAdminApi } from "../../../../../lib/admin.ts"; 11 - import { rejectIcon } from "../../../../../lib/registry.ts"; 12 - 13 - export const handler = define.handlers({ 14 - async POST(ctx) { 15 - const gate = requireAdminApi(ctx); 16 - if (!gate.ok) return gate.response; 17 - 18 - const did = decodeURIComponent(ctx.params.did); 19 - if (!did.startsWith("did:")) { 20 - return jsonError(400, "invalid_did"); 21 - } 22 - const body = await ctx.req.json().catch(() => null) as 23 - | { reason?: unknown } 24 - | null; 25 - const reason = typeof body?.reason === "string" ? body.reason.trim() : ""; 26 - if (!reason) return jsonError(400, "missing_reason"); 27 - 28 - try { 29 - await rejectIcon(did, gate.did, reason); 30 - } catch (err) { 31 - const m = err instanceof Error ? err.message : String(err); 32 - return jsonError(500, "reject_failed", m); 33 - } 34 - return new Response(JSON.stringify({ ok: true }), { 35 - status: 200, 36 - headers: { "content-type": "application/json; charset=utf-8" }, 37 - }); 38 - }, 39 - }); 40 - 41 - function jsonError(status: number, code: string, detail?: string): Response { 42 - return new Response( 43 - JSON.stringify(detail ? { error: code, detail } : { error: code }), 44 - { 45 - status, 46 - headers: { "content-type": "application/json; charset=utf-8" }, 47 - }, 48 - ); 49 - }
+3 -1
routes/api/admin/profiles/[did]/takedown.ts
··· 31 31 const body = await ctx.req.json().catch(() => null) as 32 32 | { reason?: unknown; notes?: unknown } 33 33 | null; 34 - const rawReason = typeof body?.reason === "string" ? body.reason.trim() : ""; 34 + const rawReason = typeof body?.reason === "string" 35 + ? body.reason.trim() 36 + : ""; 35 37 if (!rawReason) return jsonError(400, "missing_reason"); 36 38 const reason = rawReason.slice(0, MAX_REASON_LEN); 37 39 const notes = typeof body?.notes === "string"
+1 -2
routes/api/me/avatar.ts
··· 36 36 status: 302, 37 37 headers: { 38 38 location: `/api/registry/avatar/${encodeURIComponent(user.did)}`, 39 - "cache-control": 40 - "private, max-age=300, stale-while-revalidate=86400", 39 + "cache-control": "private, max-age=300, stale-while-revalidate=86400", 41 40 }, 42 41 }); 43 42 }
+88
routes/api/registry/icon-access/request.ts
··· 1 + /** 2 + * Authenticated user submits a verification request for SVG-icon 3 + * uploads on their own profile. 4 + * 5 + * POST /api/registry/icon-access/request { email } 6 + * 7 + * The DID is taken from the OAuth session, never from the body — the 8 + * request always targets the caller's own profile. The user must have 9 + * a published profile already; we refuse otherwise so the request row 10 + * has somewhere to live. 11 + * 12 + * Allowed transitions are enforced inside `requestIconAccess`: 13 + * - `null` or `denied` → `requested` (succeeds) 14 + * - `requested` → no-op (returns 200 idempotently) 15 + * - `granted` → 409 (caller is already verified) 16 + */ 17 + import { define } from "../../../../utils.ts"; 18 + import { 19 + getProfileByDid, 20 + requestIconAccess, 21 + } from "../../../../lib/registry.ts"; 22 + 23 + interface RequestPayload { 24 + email?: unknown; 25 + } 26 + 27 + const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 28 + 29 + export const handler = define.handlers({ 30 + async POST(ctx) { 31 + const user = ctx.state.user; 32 + if (!user) return jsonError(401, "not_authenticated"); 33 + 34 + const body = await ctx.req.json().catch(() => null) as 35 + | RequestPayload 36 + | null; 37 + if (!body || typeof body.email !== "string") { 38 + return jsonError(400, "missing_email"); 39 + } 40 + const email = body.email.trim().slice(0, 320); 41 + if (!EMAIL_RE.test(email)) return jsonError(400, "invalid_email"); 42 + 43 + const existing = await getProfileByDid(user.did, { includeTakenDown: true }) 44 + .catch(() => null); 45 + if (!existing) { 46 + return jsonError( 47 + 409, 48 + "no_profile", 49 + "Publish your profile before requesting verification.", 50 + ); 51 + } 52 + if (existing.takedownStatus === "taken_down") { 53 + return jsonError(403, "taken_down"); 54 + } 55 + if (existing.iconAccessStatus === "granted") { 56 + return jsonError(409, "already_granted"); 57 + } 58 + if (existing.iconAccessStatus === "requested") { 59 + // Idempotent — same email may have been submitted twice; we don't 60 + // want the client to surface this as an error. 61 + return jsonOk({ ok: true, status: "requested" }); 62 + } 63 + 64 + try { 65 + await requestIconAccess(user.did, email); 66 + } catch (err) { 67 + const m = err instanceof Error ? err.message : String(err); 68 + return jsonError(500, "request_failed", m); 69 + } 70 + return jsonOk({ ok: true, status: "requested" }); 71 + }, 72 + }); 73 + 74 + function jsonOk(body: unknown): Response { 75 + return new Response(JSON.stringify(body), { 76 + status: 200, 77 + headers: { "content-type": "application/json; charset=utf-8" }, 78 + }); 79 + } 80 + function jsonError(status: number, code: string, detail?: string): Response { 81 + return new Response( 82 + JSON.stringify(detail ? { error: code, detail } : { error: code }), 83 + { 84 + status, 85 + headers: { "content-type": "application/json; charset=utf-8" }, 86 + }, 87 + ); 88 + }
+14 -8
routes/api/registry/icon/[did].ts
··· 32 32 if (!profile || !profile.iconCid) { 33 33 return new Response("not found", { status: 404 }); 34 34 } 35 - /** Refuse to serve until an admin has approved this icon. The blob 36 - * itself is on the user's PDS regardless — we just gate our proxy + 37 - * iconUrl emission, which is what developer-facing API consumers 38 - * rely on. The owner is allowed to see their own pending/rejected 39 - * icon so the manage-page preview keeps working. */ 40 - if (profile.iconStatus !== "approved") { 41 - const owner = ctx.state.user?.did === did; 42 - if (!owner) return new Response("not found", { status: 404 }); 35 + /** Refuse to serve unless the project is verified AND the icon has 36 + * been auto-approved on upload. The blob itself is on the user's 37 + * PDS regardless — we just gate our proxy + iconUrl emission, 38 + * which is what developer-facing API consumers rely on. The owner 39 + * is allowed to see their own icon (any status) so the manage-page 40 + * preview keeps working through pending / denied transitions. */ 41 + const owner = ctx.state.user?.did === did; 42 + if (!owner) { 43 + if (profile.iconAccessStatus !== "granted") { 44 + return new Response("not found", { status: 404 }); 45 + } 46 + if (profile.iconStatus !== "approved") { 47 + return new Response("not found", { status: 404 }); 48 + } 43 49 } 44 50 try { 45 51 const upstream = await fetchBlobPublic(
+28 -4
routes/api/registry/profile.ts
··· 185 185 } 186 186 187 187 /** 188 - * Developer-facing SVG icon. We sanitise the bytes before upload 189 - * (strips <script>, on*, foreignObject, javascript: hrefs) so the 190 - * blob persisted on the user's PDS is already clean — even if some 191 - * other consumer fetches it directly without our serve-time CSP. 188 + * Developer-facing SVG icon. Two gates: 189 + * 190 + * 1. Per-project verification (`icon_access_status === 'granted'`). 191 + * Uploads from unverified projects are refused outright. The 192 + * form enforces this client-side too, but the API is the source 193 + * of truth. 194 + * 2. Server-side sanitiser (strips <script>, on*, foreignObject, 195 + * javascript: hrefs) before the bytes are persisted on the 196 + * user's PDS — even verified projects are sanitised every time. 197 + * 198 + * "Keeping" an existing icon (passing the BlobRef back unchanged) 199 + * also requires verification — that handles the revoke→re-save case 200 + * where we want the icon to be dropped automatically. 192 201 */ 202 + const wantsIcon = !!(body.iconUpload?.dataBase64 || body.icon); 203 + if (wantsIcon && existing?.iconAccessStatus !== "granted") { 204 + return new Response( 205 + JSON.stringify({ 206 + error: "icon_access_required", 207 + detail: 208 + "SVG icon uploads require admin verification. Request access from your profile page.", 209 + }), 210 + { 211 + status: 403, 212 + headers: { "content-type": "application/json; charset=utf-8" }, 213 + }, 214 + ); 215 + } 216 + 193 217 let icon = body.icon ?? undefined; 194 218 if (body.iconUpload?.dataBase64) { 195 219 const mime = body.iconUpload.mimeType;
+3 -1
routes/api/registry/profile/[id].ts
··· 62 62 avatarUrl: profile.avatarCid 63 63 ? `${origin}/api/registry/avatar/${encodeURIComponent(profile.did)}` 64 64 : null, 65 - iconUrl: profile.iconCid && profile.iconStatus === "approved" 65 + iconUrl: profile.iconCid && 66 + profile.iconStatus === "approved" && 67 + profile.iconAccessStatus === "granted" 66 68 ? `${origin}/api/registry/icon/${encodeURIComponent(profile.did)}` 67 69 : null, 68 70 };
+1 -3
routes/explore/[handle].tsx
··· 31 31 return ctx.render( 32 32 <ProfileDetailPage 33 33 profile={profile} 34 - signedInUser={user 35 - ? { did: user.did, handle: user.handle } 36 - : null} 34 + signedInUser={user ? { did: user.did, handle: user.handle } : null} 37 35 ownerHandle={ownerProfile?.handle ?? null} 38 36 locale={ctx.state.locale} 39 37 />,
+4 -2
routes/explore/create.tsx
··· 21 21 <div id="page-top"> 22 22 <GlassClouds /> 23 23 <div class="content-layer"> 24 - {/* user is null here (we redirect when signed in), so the menu 24 + { 25 + /* user is null here (we redirect when signed in), so the menu 25 26 * shows the "Sign in" entry — useful if someone lands on this 26 27 * page from a deep link and wants the same affordance as the 27 - * rest of the explore section. */} 28 + * rest of the explore section. */ 29 + } 28 30 <Nav account={{ user: null }} /> 29 31 <section class="explore-create" style={{ paddingTop: "8rem" }}> 30 32 <div class="container" style={{ maxWidth: "640px" }}>
+6 -2
routes/explore/manage.tsx
··· 63 63 ? { 64 64 ref: existing.iconCid, 65 65 mime: existing.iconMime, 66 - status: existing.iconStatus, 67 - rejectedReason: existing.iconRejectedReason, 68 66 } 69 67 : null, 68 + iconAccessStatus: existing.iconAccessStatus, 69 + iconAccessEmail: existing.iconAccessEmail, 70 + iconAccessDeniedReason: existing.iconAccessDeniedReason, 70 71 }; 71 72 } else { 72 73 const session = await loadSession(user.did); ··· 89 90 } 90 91 : null, 91 92 icon: null, 93 + iconAccessStatus: null, 94 + iconAccessEmail: null, 95 + iconAccessDeniedReason: null, 92 96 }; 93 97 if (bsky.avatar) { 94 98 initialAvatarUrl = bskyCdnAvatarUrl(