atmosphere explorer
0
fork

Configure Feed

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

add permission prompt when missing scopes

Juliet 5f924d49 9f2c64fc

+220 -85
+19 -2
src/auth/account.tsx
··· 1 1 import { Did } from "@atcute/lexicons"; 2 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 3 import { A } from "@solidjs/router"; 4 - import { createSignal, For, onMount, Show } from "solid-js"; 4 + import { createEffect, createSignal, For, onMount, Show } from "solid-js"; 5 5 import { createStore, produce } from "solid-js/store"; 6 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 7 import { Modal } from "../components/modal.jsx"; ··· 17 17 retrieveSession, 18 18 saveSessionToStorage, 19 19 } from "./session-manager.js"; 20 - import { agent, openManager, sessions, setAgent, setOpenManager, setSessions } from "./state.js"; 20 + import { 21 + agent, 22 + openManager, 23 + pendingPermissionEdit, 24 + sessions, 25 + setAgent, 26 + setOpenManager, 27 + setPendingPermissionEdit, 28 + setSessions, 29 + } from "./state.js"; 21 30 22 31 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 23 32 const removeSession = async (did: Did) => { ··· 71 80 72 81 const scopeFlow = useOAuthScopeFlow({ 73 82 beforeRedirect: (account) => resumeSession(account as Did), 83 + }); 84 + 85 + createEffect(() => { 86 + const pending = pendingPermissionEdit(); 87 + if (pending) { 88 + scopeFlow.initiateWithRedirect(pending); 89 + setPendingPermissionEdit(null); 90 + } 74 91 }); 75 92 76 93 const handleAccountClick = async (did: Did) => {
+1
src/auth/state.ts
··· 13 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 14 export const [sessions, setSessions] = createStore<Sessions>(); 15 15 export const [openManager, setOpenManager] = createSignal(false); 16 + export const [pendingPermissionEdit, setPendingPermissionEdit] = createSignal<string | null>(null);
+8 -3
src/components/create/confirm-submit.tsx
··· 1 1 import { createSignal, Show } from "solid-js"; 2 + import { hasUserScope } from "../../auth/scope-utils"; 2 3 import { Button } from "../button.jsx"; 3 4 4 5 export const ConfirmSubmit = (props: { ··· 57 58 <div class="flex items-center gap-2"> 58 59 <button 59 60 type="button" 60 - class="-ml-2 flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs hover:bg-neutral-200/50 dark:hover:bg-neutral-700" 61 - onClick={() => setRecreate(!recreate())} 61 + class={ 62 + hasUserScope("create") ? 63 + "-ml-2 flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs hover:bg-neutral-200/50 dark:hover:bg-neutral-700" 64 + : "-ml-2 flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs opacity-40" 65 + } 66 + onClick={() => hasUserScope("create") && setRecreate(!recreate())} 62 67 > 63 68 <span 64 69 classList={{ ··· 67 72 "lucide--square text-neutral-500 dark:text-neutral-400": !recreate(), 68 73 }} 69 74 ></span> 70 - <span>Recreate</span> 75 + <span>Recreate{hasUserScope("create") ? "" : " (create permission needed)"}</span> 71 76 </button> 72 77 </div> 73 78 <p class="text-xs text-neutral-600 dark:text-neutral-400">
+45 -14
src/components/create/index.tsx
··· 18 18 import { Button } from "../button.jsx"; 19 19 import { Modal } from "../modal.jsx"; 20 20 import { addNotification, removeNotification } from "../notification.jsx"; 21 + import { showPermissionPrompt } from "../permission-prompt"; 21 22 import { TextInput } from "../text-input.jsx"; 22 23 import Tooltip from "../tooltip.jsx"; 23 24 import { ConfirmSubmit } from "./confirm-submit"; ··· 30 31 31 32 export { editorInstance, placeholder, setPlaceholder }; 32 33 33 - export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => { 34 + export const RecordEditor = (props: { 35 + create: boolean; 36 + record?: any; 37 + refetch?: any; 38 + scope?: "create" | "update" | "delete" | "blob"; 39 + }) => { 34 40 const navigate = useNavigate(); 35 41 const params = useParams(); 36 42 const [openDialog, setOpenDialog] = createSignal(false); ··· 39 45 const [openInsertMenu, setOpenInsertMenu] = createSignal(false); 40 46 const [openHandleDialog, setOpenHandleDialog] = createSignal(false); 41 47 const [openConfirmDialog, setOpenConfirmDialog] = createSignal(false); 48 + 49 + const hasPermission = () => !props.scope || hasUserScope(props.scope); 42 50 const [isMaximized, setIsMaximized] = createSignal(false); 43 51 const [isMinimized, setIsMinimized] = createSignal(false); 44 52 const [collectionError, setCollectionError] = createSignal(""); ··· 365 373 label="Insert timestamp" 366 374 onClick={insertTimestamp} 367 375 /> 368 - <Show when={hasUserScope("blob")}> 369 - <MenuItem 370 - icon="lucide--upload" 371 - label="Upload blob" 372 - onClick={() => { 376 + <button 377 + type="button" 378 + class={ 379 + hasUserScope("blob") ? 380 + "flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 381 + : "flex items-center gap-2 rounded-md p-2 text-left text-xs opacity-40" 382 + } 383 + onClick={() => { 384 + if (hasUserScope("blob")) { 373 385 setOpenInsertMenu(false); 374 386 blobInput.click(); 375 - }} 376 - /> 377 - </Show> 387 + } 388 + }} 389 + > 390 + <span class="iconify lucide--upload shrink-0"></span> 391 + <span>Upload blob{hasUserScope("blob") ? "" : " (permission needed)"}</span> 392 + </button> 378 393 </div> 379 394 </Show> 380 395 <input ··· 441 456 <span class="text-sm font-medium">{props.create ? "Creating" : "Editing"} record</span> 442 457 </button> 443 458 </Show> 444 - <Tooltip text={props.create ? "Create record (n)" : "Edit record (e)"}> 459 + <Tooltip 460 + text={ 461 + hasPermission() ? 462 + props.create ? 463 + "Create record (n)" 464 + : "Edit record (e)" 465 + : `${props.create ? "Create record" : "Edit record"} (permission required)` 466 + } 467 + > 445 468 <button 446 - class={`flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}`} 469 + class={ 470 + hasPermission() ? 471 + `flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}` 472 + : `flex items-center p-1.5 opacity-40 ${props.create ? "rounded-lg" : "rounded-sm"}` 473 + } 447 474 onclick={() => { 448 - setNotice(""); 449 - setOpenDialog(true); 450 - setIsMinimized(false); 475 + if (hasPermission()) { 476 + setNotice(""); 477 + setOpenDialog(true); 478 + setIsMinimized(false); 479 + } else if (props.scope) { 480 + showPermissionPrompt(props.scope); 481 + } 451 482 }} 452 483 > 453 484 <div
+39
src/components/permission-button.tsx
··· 1 + import { JSX } from "solid-js"; 2 + import { hasUserScope } from "../auth/scope-utils"; 3 + import { showPermissionPrompt } from "./permission-prompt"; 4 + import Tooltip from "./tooltip"; 5 + 6 + export interface PermissionButtonProps { 7 + scope: "create" | "update" | "delete" | "blob"; 8 + tooltip: string; 9 + class?: string; 10 + disabledClass?: string; 11 + onClick: () => void; 12 + children: JSX.Element; 13 + } 14 + 15 + export const PermissionButton = (props: PermissionButtonProps) => { 16 + const hasPermission = () => hasUserScope(props.scope); 17 + 18 + const handleClick = () => { 19 + if (hasPermission()) { 20 + props.onClick(); 21 + } else { 22 + showPermissionPrompt(props.scope); 23 + } 24 + }; 25 + 26 + const baseClass = 27 + props.class || 28 + "flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"; 29 + const disabledClass = 30 + props.disabledClass || "flex items-center rounded-sm p-1.5 opacity-40 cursor-not-allowed"; 31 + 32 + return ( 33 + <Tooltip text={hasPermission() ? props.tooltip : `${props.tooltip} (permission required)`}> 34 + <button class={hasPermission() ? baseClass : disabledClass} onclick={handleClick}> 35 + {props.children} 36 + </button> 37 + </Tooltip> 38 + ); 39 + };
+48
src/components/permission-prompt.tsx
··· 1 + import { createSignal } from "solid-js"; 2 + import { GRANULAR_SCOPES } from "../auth/scope-utils"; 3 + import { agent, setOpenManager, setPendingPermissionEdit } from "../auth/state"; 4 + import { Button } from "./button"; 5 + import { Modal } from "./modal"; 6 + 7 + type ScopeId = "create" | "update" | "delete" | "blob"; 8 + 9 + const [requestedScope, setRequestedScope] = createSignal<ScopeId | null>(null); 10 + 11 + export const showPermissionPrompt = (scope: ScopeId) => { 12 + setRequestedScope(scope); 13 + }; 14 + 15 + export const PermissionPromptContainer = () => { 16 + const scopeLabel = () => { 17 + const scope = GRANULAR_SCOPES.find((s) => s.id === requestedScope()); 18 + return scope?.label.toLowerCase() || requestedScope(); 19 + }; 20 + 21 + const handleEditPermissions = () => { 22 + setRequestedScope(null); 23 + if (agent()) { 24 + setPendingPermissionEdit(agent()!.sub); 25 + setOpenManager(true); 26 + } 27 + }; 28 + 29 + return ( 30 + <Modal open={requestedScope() !== null} onClose={() => setRequestedScope(null)}> 31 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 32 + <h2 class="mb-2 font-semibold">Permission required</h2> 33 + <p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400"> 34 + You need the "{scopeLabel()}" permission to perform this action. 35 + </p> 36 + <div class="flex justify-end gap-2"> 37 + <Button onClick={() => setRequestedScope(null)}>Cancel</Button> 38 + <Button 39 + onClick={handleEditPermissions} 40 + class="dark:shadow-dark-700 rounded-lg bg-blue-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500 dark:active:bg-blue-400" 41 + > 42 + Edit permissions 43 + </Button> 44 + </div> 45 + </div> 46 + </Modal> 47 + ); 48 + };
+4 -3
src/layout.tsx
··· 3 3 import { A, RouteSectionProps, useLocation, useNavigate } from "@solidjs/router"; 4 4 import { createEffect, ErrorBoundary, onCleanup, onMount, Show, Suspense } from "solid-js"; 5 5 import { AccountManager } from "./auth/account.jsx"; 6 - import { hasUserScope } from "./auth/scope-utils"; 7 6 import { agent } from "./auth/state.js"; 8 7 import { RecordEditor } from "./components/create"; 9 8 import { DropdownMenu, MenuProvider, NavMenu } from "./components/dropdown.jsx"; 10 9 import { NavBar } from "./components/navbar.jsx"; 11 10 import { NotificationContainer } from "./components/notification.jsx"; 11 + import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 12 12 import { Search, SearchButton, showSearch } from "./components/search.jsx"; 13 13 import { themeEvent } from "./components/theme.jsx"; 14 14 import { resolveHandle } from "./utils/api.js"; ··· 151 151 </A> 152 152 <div class="relative flex items-center gap-0.5 rounded-lg bg-neutral-50/60 px-1 py-0.5 dark:bg-neutral-800/60"> 153 153 <SearchButton /> 154 - <Show when={hasUserScope("create")}> 155 - <RecordEditor create={true} /> 154 + <Show when={agent()}> 155 + <RecordEditor create={true} scope="create" /> 156 156 </Show> 157 157 <AccountManager /> 158 158 <MenuProvider> ··· 188 188 </Show> 189 189 </div> 190 190 <NotificationContainer /> 191 + <PermissionPromptContainer /> 191 192 <Show when={plcDirectory() !== "https://plc.directory"}> 192 193 <div class="dark:bg-dark-500 fixed right-0 bottom-0 left-0 z-10 flex items-center justify-center bg-neutral-100 px-3 py-1 text-xs"> 193 194 <span>
+29 -35
src/views/collection.tsx
··· 6 6 import { A, useBeforeLeave, useParams, useSearchParams } from "@solidjs/router"; 7 7 import { createMemo, createResource, createSignal, For, onMount, Show } from "solid-js"; 8 8 import { createStore } from "solid-js/store"; 9 - import { hasUserScope } from "../auth/scope-utils"; 10 9 import { agent } from "../auth/state"; 11 10 import { Button } from "../components/button.jsx"; 12 11 import HoverCard from "../components/hover-card/base"; 13 12 import { JSONType, JSONValue } from "../components/json.jsx"; 14 13 import { Modal } from "../components/modal.jsx"; 15 14 import { addNotification, removeNotification } from "../components/notification.jsx"; 15 + import { PermissionButton } from "../components/permission-button.jsx"; 16 16 import { StickyOverlay } from "../components/sticky.jsx"; 17 17 import { TextInput } from "../components/text-input.jsx"; 18 18 import Tooltip from "../components/tooltip.jsx"; ··· 243 243 <StickyOverlay> 244 244 <div class="flex w-full flex-col gap-2"> 245 245 <div class="flex items-center gap-1.5"> 246 - <Show when={agent() && agent()?.sub === did && hasUserScope("delete")}> 246 + <Show when={agent() && agent()?.sub === did}> 247 247 <div class="flex items-center"> 248 - <Tooltip 249 - text={batchDelete() ? "Cancel" : "Manage"} 250 - children={ 251 - <button 252 - onclick={() => { 253 - setRecords({ from: 0, to: records.length - 1 }, "toDelete", false); 254 - setLastSelected(undefined); 255 - setBatchDelete(!batchDelete()); 256 - }} 257 - class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 258 - > 259 - <span 260 - class={`iconify ${batchDelete() ? "lucide--x" : "lucide--trash-2"} `} 261 - ></span> 262 - </button> 263 - } 264 - /> 248 + <PermissionButton 249 + scope="delete" 250 + tooltip={batchDelete() ? "Cancel" : "Manage"} 251 + class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 252 + disabledClass="flex items-center rounded-md p-1.5 opacity-40" 253 + onClick={() => { 254 + setRecords({ from: 0, to: records.length - 1 }, "toDelete", false); 255 + setLastSelected(undefined); 256 + setBatchDelete(!batchDelete()); 257 + }} 258 + > 259 + <span 260 + class={`iconify ${batchDelete() ? "lucide--x" : "lucide--trash-2"} `} 261 + ></span> 262 + </PermissionButton> 265 263 <Show when={batchDelete()}> 266 264 <Tooltip 267 265 text="Select all" ··· 274 272 </button> 275 273 } 276 274 /> 277 - <Show when={hasUserScope("create")}> 278 - <Tooltip 279 - text="Recreate" 280 - children={ 281 - <button 282 - onclick={() => { 283 - setRecreate(true); 284 - setOpenDelete(true); 285 - }} 286 - class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 287 - > 288 - <span class="iconify lucide--recycle text-green-500 dark:text-green-400"></span> 289 - </button> 290 - } 291 - /> 292 - </Show> 275 + <PermissionButton 276 + scope="create" 277 + tooltip="Recreate" 278 + class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 279 + disabledClass="flex items-center rounded-md p-1.5 opacity-40" 280 + onClick={() => { 281 + setRecreate(true); 282 + setOpenDelete(true); 283 + }} 284 + > 285 + <span class="iconify lucide--recycle text-green-500 dark:text-green-400"></span> 286 + </PermissionButton> 293 287 <Tooltip 294 288 text="Delete" 295 289 children={
+27 -28
src/views/record.tsx
··· 9 9 import { Title } from "@solidjs/meta"; 10 10 import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 11 11 import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 12 - import { hasUserScope } from "../auth/scope-utils"; 13 12 import { agent } from "../auth/state"; 14 13 import { Backlinks } from "../components/backlinks.jsx"; 15 14 import { Button } from "../components/button.jsx"; ··· 26 25 import { Modal } from "../components/modal.jsx"; 27 26 import { pds } from "../components/navbar.jsx"; 28 27 import { addNotification, removeNotification } from "../components/notification.jsx"; 29 - import Tooltip from "../components/tooltip.jsx"; 28 + import { PermissionButton } from "../components/permission-button.jsx"; 30 29 import { 31 30 didDocumentResolver, 32 31 resolveLexiconAuthority, ··· 406 405 </div> 407 406 <div class="flex gap-0.5"> 408 407 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 409 - <Show when={hasUserScope("update")}> 410 - <RecordEditor create={false} record={record()?.value} refetch={refetch} /> 411 - </Show> 412 - <Show when={hasUserScope("delete")}> 413 - <Tooltip text="Delete"> 414 - <button 415 - class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 416 - onclick={() => setOpenDelete(true)} 417 - > 418 - <span class="iconify lucide--trash-2"></span> 419 - </button> 420 - </Tooltip> 421 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 422 - <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 423 - <h2 class="mb-2 font-semibold">Delete this record?</h2> 424 - <div class="flex justify-end gap-2"> 425 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 426 - <Button 427 - onClick={deleteRecord} 428 - class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 429 - > 430 - Delete 431 - </Button> 432 - </div> 408 + <RecordEditor 409 + create={false} 410 + record={record()?.value} 411 + refetch={refetch} 412 + scope="update" 413 + /> 414 + <PermissionButton 415 + scope="delete" 416 + tooltip="Delete" 417 + onClick={() => setOpenDelete(true)} 418 + > 419 + <span class="iconify lucide--trash-2"></span> 420 + </PermissionButton> 421 + <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 422 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 423 + <h2 class="mb-2 font-semibold">Delete this record?</h2> 424 + <div class="flex justify-end gap-2"> 425 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 426 + <Button 427 + onClick={deleteRecord} 428 + class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 429 + > 430 + Delete 431 + </Button> 433 432 </div> 434 - </Modal> 435 - </Show> 433 + </div> 434 + </Modal> 436 435 </Show> 437 436 <MenuProvider> 438 437 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5">