this repo has no description
1
fork

Configure Feed

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

Port workspace settings UI, wire SSE keyring events to workspace store

+839 -7
+1
Cargo.lock
··· 1383 1383 "serde_json", 1384 1384 "wasm-bindgen", 1385 1385 "wasm-bindgen-futures", 1386 + "web-sys", 1386 1387 ] 1387 1388 1388 1389 [[package]]
+11
apps/web/src/components/cabinet/FileView.tsx
··· 9 9 FolderPlusIcon, 10 10 UploadSimpleIcon, 11 11 NotePencilIcon, 12 + GearIcon, 12 13 } from "@phosphor-icons/react"; 13 14 import { PanelShell } from "./PanelShell"; 14 15 import { PanelContent } from "./PanelContent"; ··· 293 294 > 294 295 <UploadSimpleIcon size={15} /> 295 296 </button> 297 + {context.kind === "workspace" && ( 298 + <Link 299 + to="/cabinet/workspace-settings/$rkey" 300 + params={{ rkey: rkeyFromUri(context.keyringUri) }} 301 + className="btn btn-ghost btn-xs btn-square rounded-md" 302 + aria-label="Workspace settings" 303 + > 304 + <GearIcon size={15} /> 305 + </Link> 306 + )} 296 307 <SegmentedToggle 297 308 options={[ 298 309 { value: "list" as const, icon: ListBulletsIcon },
+2 -1
apps/web/src/components/cabinet/WorkspaceMembersDialog.tsx
··· 40 40 >(function WorkspaceMembersDialog({ members, isManager, onRemoveMember, onAddMember }, ref) { 41 41 const dialogRef = useRef<HTMLDialogElement>(null); 42 42 const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null); 43 - const [profiles, setProfiles] = useState<Readonly<Record<string, MemberProfile>>>({}); 43 + // `null` = resolved but no profile available; absence = not yet resolved. 44 + const [profiles, setProfiles] = useState<Readonly<Record<string, MemberProfile | null>>>({}); 44 45 const [visible, setVisible] = useState(false); 45 46 46 47 const session = useAuthStore((s) => s.session);
+67 -2
apps/web/src/lib/profileResolution.ts
··· 1 - // Stubbed — may be replaced by @opake/sdk or kept as app-level utility 2 - throw new Error("unimplemented"); 1 + // Best-effort resolution of a DID → display profile (handle + avatar). 2 + // 3 + // Uses Bluesky's public appview so we don't pay DPoP setup just to render 4 + // a member list. If the account isn't on bsky, or the fetch fails for 5 + // any reason, we degrade gracefully to a null profile and callers fall 6 + // back to the raw DID as display text. 7 + // 8 + // Results are memoized per DID for the lifetime of the page — the bsky 9 + // appview is already cached at the CDN, but coalescing local duplicates 10 + // avoids N parallel fetches when a workspace has many members. 11 + 12 + const PUBLIC_API = "https://public.api.bsky.app"; 13 + 14 + export interface MemberProfile { 15 + readonly handle: string | null; 16 + readonly avatarUrl: string | null; 17 + } 18 + 19 + interface RawProfile { 20 + readonly handle?: string; 21 + readonly avatar?: string; 22 + } 23 + 24 + const cache = new Map<string, Promise<MemberProfile | null>>(); 25 + 26 + /** Only accept avatar URLs from known Bluesky CDN origins. */ 27 + function isSafeCdnUrl(url: string): boolean { 28 + try { 29 + const parsed = new URL(url); 30 + return parsed.protocol === "https:" && parsed.hostname.endsWith(".bsky.app"); 31 + } catch { 32 + return false; 33 + } 34 + } 35 + 36 + async function fetchProfile(did: string): Promise<MemberProfile | null> { 37 + try { 38 + const response = await fetch( 39 + `${PUBLIC_API}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, 40 + ); 41 + if (!response.ok) return null; 42 + const raw = (await response.json()) as RawProfile; 43 + return { 44 + handle: raw.handle ?? null, 45 + avatarUrl: raw.avatar && isSafeCdnUrl(raw.avatar) ? raw.avatar : null, 46 + }; 47 + } catch { 48 + return null; 49 + } 50 + } 51 + 52 + /** 53 + * Resolve a DID's display profile. Memoized for the page lifetime. 54 + * 55 + * @param did - The member DID to look up. 56 + * @returns Profile with handle + avatar, or `null` if resolution failed. 57 + */ 58 + export function resolveMemberProfile(did: string): Promise<MemberProfile | null> { 59 + const cached = cache.get(did); 60 + if (cached) return cached; 61 + const pending = fetchProfile(did); 62 + // Module-local cache — the immutability rule misreads this as a domain 63 + // concern. It's a fetch-dedup singleton with page lifetime. 64 + // eslint-disable-next-line functional/immutable-data 65 + cache.set(did, pending); 66 + return pending; 67 + }
+24 -2
apps/web/src/lib/workspaceSchemas.ts
··· 1 - // Stubbed — replaced by @opake/sdk 2 - throw new Error("unimplemented"); 1 + // Web-side view models for workspace member management. 2 + // 3 + // The SDK's `WorkspaceMember` type carries the full wrapped-key structure 4 + // required for crypto operations. UI components only need a flat 5 + // `{ did, role }` view — this module bridges the two and re-exports 6 + // `WorkspaceRole` so components don't reach across the SDK boundary. 7 + 8 + import type { WorkspaceMember, WorkspaceRole } from "@opake/sdk"; 9 + 10 + export type { WorkspaceRole }; 11 + 12 + /** Flat member shape for UI rendering — just DID and role, no crypto material. */ 13 + export interface KeyringMemberEntry { 14 + readonly did: string; 15 + readonly role: WorkspaceRole; 16 + } 17 + 18 + /** Project a raw `WorkspaceMember` onto the UI-facing `KeyringMemberEntry`. */ 19 + export function toMemberEntry(member: WorkspaceMember): KeyringMemberEntry { 20 + return { 21 + did: member.wrappedKey.did, 22 + role: member.role, 23 + }; 24 + }
+633 -2
apps/web/src/routes/cabinet/workspace-settings/$rkey.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 3 + import { 4 + ArrowLeftIcon, 5 + UsersIcon, 6 + CrownIcon, 7 + PencilSimpleIcon, 8 + EyeIcon, 9 + UserMinusIcon, 10 + UserPlusIcon, 11 + SignOutIcon, 12 + TrashIcon, 13 + } from "@phosphor-icons/react"; 14 + import type { WorkspaceMember } from "@opake/sdk"; 15 + import { DestructiveConfirmation } from "@/components/DestructiveConfirmation"; 16 + import { PanelShell } from "@/components/cabinet/PanelShell"; 17 + import { OpakeLogoSquares } from "@/components/OpakeLogoSquares"; 18 + import { Breadcrumbs, BreadcrumbActive } from "@/components/cabinet/Breadcrumbs"; 19 + import { AddMemberDialog, type AddMemberDialogHandle } from "@/components/cabinet/AddMemberDialog"; 20 + import { getOpake, useAuthStore } from "@/stores/auth"; 21 + import { useWorkspaceStore } from "@/stores/workspace"; 22 + import { toastError, toastSuccess } from "@/stores/toast"; 23 + import { rkeyFromUri } from "@/lib/atUri"; 24 + import { resolveMemberProfile, type MemberProfile } from "@/lib/profileResolution"; 25 + import { toMemberEntry, type KeyringMemberEntry, type WorkspaceRole } from "@/lib/workspaceSchemas"; 26 + import { loading } from "@/stores/app"; 27 + 28 + // --------------------------------------------------------------------------- 29 + // Constants 30 + // --------------------------------------------------------------------------- 31 + 32 + const ROLE_ICON: Readonly<Record<WorkspaceRole, typeof CrownIcon>> = { 33 + manager: CrownIcon, 34 + editor: PencilSimpleIcon, 35 + viewer: EyeIcon, 36 + }; 37 + 38 + const ROLE_OPTIONS: readonly WorkspaceRole[] = ["manager", "editor", "viewer"]; 39 + 40 + // --------------------------------------------------------------------------- 41 + // Settings page 42 + // --------------------------------------------------------------------------- 43 + 44 + function WorkspaceSettingsPage() { 45 + const { rkey } = Route.useParams(); 46 + const navigate = useNavigate(); 47 + const addMemberDialogRef = useRef<AddMemberDialogHandle>(null); 48 + const iconInputRef = useRef<HTMLInputElement>(null); 49 + 50 + // Current session 51 + const session = useAuthStore((s) => s.session); 52 + const myDid = session.status === "active" ? session.did : null; 53 + 54 + // Workspace metadata from the sidebar store (loaded on cabinet mount) 55 + const workspace = useWorkspaceStore((s) => 56 + Object.values(s.workspaces).find((w) => rkeyFromUri(w.uri) === rkey), 57 + ); 58 + const keyringUri = workspace?.uri ?? null; 59 + 60 + // Members + key material — fetched on-demand for this page 61 + const [rawMembers, setRawMembers] = useState<readonly WorkspaceMember[]>([]); 62 + const [groupKey, setGroupKey] = useState<Uint8Array | null>(null); 63 + const [loadError, setLoadError] = useState<string | null>(null); 64 + const members: readonly KeyringMemberEntry[] = useMemo( 65 + () => rawMembers.map(toMemberEntry), 66 + [rawMembers], 67 + ); 68 + 69 + // Editable metadata — override pattern lets us track "dirty" without 70 + // wiping the seed when the underlying record re-renders 71 + const seedName = workspace?.name ?? ""; 72 + const seedDescription = workspace?.description ?? ""; 73 + const seedIcon = workspace?.icon ?? null; 74 + const [nameOverride, setNameOverride] = useState<string | null>(null); 75 + const [descriptionOverride, setDescriptionOverride] = useState<string | null>(null); 76 + const [iconOverride, setIconOverride] = useState<string | null>(null); 77 + const name = nameOverride ?? seedName; 78 + const description = descriptionOverride ?? seedDescription; 79 + const icon = iconOverride ?? seedIcon; 80 + const metaDirty = nameOverride !== null || descriptionOverride !== null || iconOverride !== null; 81 + 82 + // Profile resolution for member rows 83 + const [profiles, setProfiles] = useState<Readonly<Record<string, MemberProfile | null>>>({}); 84 + 85 + // Confirm state (two-click for remove + leave, phrase-typing for delete) 86 + const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null); 87 + const [confirmingLeave, setConfirmingLeave] = useState(false); 88 + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); 89 + 90 + const role = useMemo(() => members.find((m) => m.did === myDid)?.role ?? null, [members, myDid]); 91 + const isManager = role === "manager"; 92 + const isOwner = myDid !== null && myDid === workspace?.ownerDid; 93 + const canManage = isOwner || isManager; 94 + 95 + // ----------------------------------------------------------------- 96 + // Loaders 97 + // ----------------------------------------------------------------- 98 + 99 + const reloadMembersAndKey = useCallback(async (uri: string) => { 100 + const opake = getOpake(); 101 + const ms = await opake.listWorkspaceMembers(uri); 102 + setRawMembers(ms); 103 + // Re-unwrap — owner rotations change the key, additions don't 104 + const key = await opake.unwrapGroupKey(ms); 105 + setGroupKey(key); 106 + }, []); 107 + 108 + // Refresh after a member mutation. Pulls the fresh member list + key 109 + // from the PDS (no appview lag on that path), then optimistically 110 + // patches the sidebar's member count. We deliberately skip calling 111 + // `loadWorkspaces()` because it would re-fetch via appview and clobber 112 + // any optimistic metadata patch from a prior save. The visibility 113 + // listener reconciles the full workspace record eventually. 114 + const refreshAfterMemberChange = useCallback( 115 + async (uri: string, memberCountDelta: number) => { 116 + await reloadMembersAndKey(uri); 117 + useWorkspaceStore.setState((draft) => { 118 + if (!(uri in draft.workspaces)) return; 119 + const w = draft.workspaces[uri]; 120 + draft.workspaces[uri] = { 121 + ...w, 122 + memberCount: Math.max(0, w.memberCount + memberCountDelta), 123 + }; 124 + }); 125 + }, 126 + [reloadMembersAndKey], 127 + ); 128 + 129 + // Initial load 130 + useEffect(() => { 131 + if (!keyringUri) return; 132 + const uri = keyringUri; 133 + const done = loading("workspace-settings-load"); 134 + (async () => { 135 + try { 136 + await reloadMembersAndKey(uri); 137 + setLoadError(null); 138 + } catch (err) { 139 + setLoadError(err instanceof Error ? err.message : "Failed to load members"); 140 + } finally { 141 + done(); 142 + } 143 + })().catch((err: unknown) => { 144 + console.error("[workspace-settings] load failed:", err); 145 + }); 146 + }, [keyringUri, reloadMembersAndKey]); 147 + 148 + // Profile resolution 149 + useEffect(() => { 150 + if (members.length === 0) return; 151 + const unresolved = members.map((m) => m.did).filter((did) => !(did in profiles)); 152 + if (unresolved.length === 0) return; 153 + void Promise.all( 154 + unresolved.map(async (did) => { 155 + const profile = await resolveMemberProfile(did); 156 + setProfiles((prev) => ({ ...prev, [did]: profile })); 157 + }), 158 + ); 159 + }, [members, profiles]); 160 + 161 + // ----------------------------------------------------------------- 162 + // Handlers 163 + // ----------------------------------------------------------------- 164 + 165 + const handleSaveMetadata = useCallback(() => { 166 + if (!keyringUri || !groupKey || !name.trim()) return; 167 + const uri = keyringUri; 168 + const done = loading("save-workspace-metadata"); 169 + (async () => { 170 + try { 171 + const nextName = nameOverride != null ? name.trim() : undefined; 172 + const nextDesc = descriptionOverride != null ? description.trim() : undefined; 173 + const nextIcon = iconOverride ?? undefined; 174 + await getOpake().updateWorkspaceMetadata(uri, groupKey, { 175 + name: nextName, 176 + description: nextDesc, 177 + icon: nextIcon, 178 + }); 179 + toastSuccess("Workspace updated"); 180 + 181 + // Optimistic patch: the appview has a 4s cursor lag, so 182 + // `loadWorkspaces` would return stale data and blank the form. 183 + // Update the store locally with the known-saved values; the 184 + // visibility listener (or a future SSE keyring callback) will 185 + // eventually reconcile against the real record. 186 + useWorkspaceStore.setState((draft) => { 187 + if (!(uri in draft.workspaces)) return; 188 + const w = draft.workspaces[uri]; 189 + draft.workspaces[uri] = { 190 + ...w, 191 + name: nextName ?? w.name, 192 + description: nextDesc ?? w.description, 193 + icon: nextIcon ?? w.icon, 194 + }; 195 + }); 196 + setNameOverride(null); 197 + setDescriptionOverride(null); 198 + setIconOverride(null); 199 + } catch (err) { 200 + toastError(err instanceof Error ? err.message : "Failed to update workspace"); 201 + } finally { 202 + done(); 203 + } 204 + })().catch((err: unknown) => { 205 + console.error("[workspace-settings] save failed:", err); 206 + }); 207 + }, [keyringUri, groupKey, name, description, nameOverride, descriptionOverride, iconOverride]); 208 + 209 + const handleIconSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { 210 + const file = e.target.files?.[0]; 211 + if (!file) return; 212 + 213 + // FileReader + canvas pipeline: decode → resize to 128×128 → base64 214 + const reader = new FileReader(); 215 + // eslint-disable-next-line functional/immutable-data -- FileReader callback pattern 216 + reader.onload = () => { 217 + const img = new Image(); 218 + // eslint-disable-next-line functional/immutable-data -- Image callback pattern 219 + img.onload = () => { 220 + const canvas = document.createElement("canvas"); 221 + // eslint-disable-next-line functional/immutable-data 222 + canvas.width = 128; 223 + // eslint-disable-next-line functional/immutable-data 224 + canvas.height = 128; 225 + const ctx = canvas.getContext("2d"); 226 + if (!ctx) return; 227 + ctx.drawImage(img, 0, 0, 128, 128); 228 + const dataUrl = canvas.toDataURL("image/png"); 229 + setIconOverride(dataUrl.split(",")[1] ?? null); 230 + }; 231 + // eslint-disable-next-line functional/immutable-data -- Image src setter triggers load 232 + img.src = reader.result as string; 233 + }; 234 + reader.readAsDataURL(file); 235 + }, []); 236 + 237 + const handleRemove = useCallback( 238 + (memberDid: string) => { 239 + if (!keyringUri || !groupKey) return; 240 + if (confirmingRemove !== memberDid) { 241 + setConfirmingRemove(memberDid); 242 + return; 243 + } 244 + setConfirmingRemove(null); 245 + 246 + const uri = keyringUri; 247 + const done = loading("remove-workspace-member"); 248 + (async () => { 249 + try { 250 + const result = await getOpake().removeWorkspaceMember(uri, groupKey, memberDid); 251 + toastSuccess(result.proposed ? "Removal proposed" : "Member removed, key rotated"); 252 + await refreshAfterMemberChange(uri, -1); 253 + } catch (err) { 254 + toastError(err instanceof Error ? err.message : "Failed to remove member"); 255 + } finally { 256 + done(); 257 + } 258 + })().catch((err: unknown) => { 259 + console.error("[workspace-settings] remove failed:", err); 260 + }); 261 + }, 262 + [keyringUri, groupKey, confirmingRemove, refreshAfterMemberChange], 263 + ); 264 + 265 + const handleLeave = useCallback(() => { 266 + if (!keyringUri) return; 267 + if (!confirmingLeave) { 268 + setConfirmingLeave(true); 269 + return; 270 + } 271 + const uri = keyringUri; 272 + const done = loading("leave-workspace"); 273 + (async () => { 274 + try { 275 + await getOpake().leaveWorkspace(uri); 276 + toastSuccess("Left workspace"); 277 + await useWorkspaceStore.getState().loadWorkspaces(); 278 + void navigate({ to: "/cabinet/files" }); 279 + } catch (err) { 280 + toastError(err instanceof Error ? err.message : "Failed to leave workspace"); 281 + } finally { 282 + done(); 283 + } 284 + })().catch((err: unknown) => { 285 + console.error("[workspace-settings] leave failed:", err); 286 + }); 287 + }, [keyringUri, confirmingLeave, navigate]); 288 + 289 + const handleDeleteWorkspace = useCallback(() => { 290 + if (!keyringUri) return; 291 + // TODO: workspace deletion not yet implemented in core. Needs purge of 292 + // docs + directories + keyring record as a single transaction. For now 293 + // the confirmation just navigates away — no destructive action taken. 294 + toastError("Delete workspace not yet implemented"); 295 + void navigate({ to: "/cabinet/files" }); 296 + }, [keyringUri, navigate]); 297 + 298 + const handleAddMember = useCallback( 299 + (handle: string, memberRole: WorkspaceRole) => { 300 + if (!keyringUri || !groupKey) return; 301 + const uri = keyringUri; 302 + const done = loading("add-workspace-member"); 303 + (async () => { 304 + try { 305 + const opake = getOpake(); 306 + const identity = await opake.resolveIdentity(handle); 307 + await opake.addWorkspaceMember( 308 + uri, 309 + groupKey, 310 + identity.did, 311 + identity.publicKey, 312 + memberRole, 313 + ); 314 + toastSuccess(`Added ${identity.handle ?? identity.did}`); 315 + await refreshAfterMemberChange(uri, 1); 316 + } catch (err) { 317 + toastError(err instanceof Error ? err.message : "Failed to add member"); 318 + } finally { 319 + done(); 320 + } 321 + })().catch((err: unknown) => { 322 + console.error("[workspace-settings] add failed:", err); 323 + }); 324 + }, 325 + [keyringUri, groupKey, refreshAfterMemberChange], 326 + ); 327 + 328 + const handleRoleChange = useCallback( 329 + (memberDid: string, newRole: WorkspaceRole) => { 330 + if (!keyringUri) return; 331 + const uri = keyringUri; 332 + const done = loading("update-member-role"); 333 + (async () => { 334 + try { 335 + await getOpake().updateMemberRole(uri, memberDid, newRole); 336 + toastSuccess("Role updated"); 337 + await refreshAfterMemberChange(uri, 0); 338 + } catch (err) { 339 + toastError(err instanceof Error ? err.message : "Failed to update role"); 340 + } finally { 341 + done(); 342 + } 343 + })().catch((err: unknown) => { 344 + console.error("[workspace-settings] role change failed:", err); 345 + }); 346 + }, 347 + [keyringUri, refreshAfterMemberChange], 348 + ); 349 + 350 + // ----------------------------------------------------------------- 351 + // Render 352 + // ----------------------------------------------------------------- 353 + 354 + const workspaceName = workspace?.name ?? "Workspace"; 355 + 356 + const breadcrumbs = ( 357 + <Breadcrumbs> 358 + <li> 359 + <Link to="/cabinet/workspace/$rkey" params={{ rkey }} className="text-text-faint"> 360 + <UsersIcon size={14} className="mr-1.5 inline md:hidden" /> 361 + {workspaceName} 362 + </Link> 363 + </li> 364 + <BreadcrumbActive>Settings</BreadcrumbActive> 365 + </Breadcrumbs> 366 + ); 367 + 368 + const toolbar = ( 369 + <Link 370 + to="/cabinet/workspace/$rkey" 371 + params={{ rkey }} 372 + className="btn btn-ghost btn-sm gap-1.5 rounded-lg text-xs" 373 + > 374 + <ArrowLeftIcon size={13} /> 375 + Back 376 + </Link> 377 + ); 378 + 379 + if (!workspace) { 380 + return ( 381 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Loading…"> 382 + <div className="flex h-full items-center justify-center"> 383 + <OpakeLogoSquares size="lg" loading /> 384 + </div> 385 + </PanelShell> 386 + ); 387 + } 388 + 389 + return ( 390 + <PanelShell depth={1} breadcrumbs={breadcrumbs} toolbar={toolbar} footer="End-to-end encrypted"> 391 + <div className="mx-auto max-w-lg space-y-8 px-6 py-6"> 392 + {/* Metadata */} 393 + <section> 394 + <h2 className="text-base-content mb-3 text-sm font-semibold">Workspace</h2> 395 + <div className="space-y-3"> 396 + <div className="flex items-center gap-4"> 397 + <button 398 + onClick={() => iconInputRef.current?.click()} 399 + className="group relative shrink-0" 400 + aria-label="Change workspace icon" 401 + disabled={!canManage} 402 + > 403 + {icon ? ( 404 + <img 405 + src={`data:image/png;base64,${icon}`} 406 + alt="" 407 + className="size-14 rounded-xl object-cover" 408 + /> 409 + ) : ( 410 + <div className="bg-accent text-primary flex size-14 items-center justify-center rounded-xl text-xl font-semibold"> 411 + {name[0].toUpperCase()} 412 + </div> 413 + )} 414 + {canManage && ( 415 + <div className="bg-base-content/50 absolute inset-0 flex items-center justify-center rounded-xl opacity-0 transition-opacity group-hover:opacity-100"> 416 + <PencilSimpleIcon size={16} className="text-white" /> 417 + </div> 418 + )} 419 + </button> 420 + <input 421 + ref={iconInputRef} 422 + type="file" 423 + accept="image/*" 424 + onChange={handleIconSelected} 425 + className="sr-only" 426 + aria-hidden="true" 427 + /> 428 + <div className="flex-1"> 429 + <span className="text-ui text-base-content block">{name || "Unnamed"}</span> 430 + <span className="text-caption text-text-faint"> 431 + {description || "No description"} 432 + </span> 433 + </div> 434 + </div> 435 + <div> 436 + <label htmlFor="ws-name" className="text-caption text-text-muted mb-1 block"> 437 + Name 438 + </label> 439 + <input 440 + id="ws-name" 441 + type="text" 442 + value={name} 443 + onChange={(e) => setNameOverride(e.target.value)} 444 + disabled={!canManage} 445 + className="input input-bordered input-sm border-base-300/50 w-full rounded-lg text-xs" 446 + /> 447 + </div> 448 + <div> 449 + <label htmlFor="ws-desc" className="text-caption text-text-muted mb-1 block"> 450 + Description 451 + </label> 452 + <textarea 453 + id="ws-desc" 454 + value={description} 455 + onChange={(e) => setDescriptionOverride(e.target.value)} 456 + disabled={!canManage} 457 + className="textarea textarea-bordered textarea-sm border-base-300/50 w-full rounded-lg text-xs" 458 + rows={2} 459 + /> 460 + </div> 461 + {metaDirty && ( 462 + <button 463 + onClick={handleSaveMetadata} 464 + disabled={!name.trim() || !groupKey} 465 + className="btn btn-primary btn-sm rounded-lg text-xs" 466 + > 467 + Save changes 468 + </button> 469 + )} 470 + </div> 471 + </section> 472 + 473 + {/* Members */} 474 + <section> 475 + <div className="mb-3 flex items-center justify-between"> 476 + <h2 className="text-base-content text-sm font-semibold">Members ({members.length})</h2> 477 + {isManager && ( 478 + <button 479 + onClick={() => addMemberDialogRef.current?.show()} 480 + className="btn btn-ghost btn-xs gap-1 rounded-lg" 481 + > 482 + <UserPlusIcon size={13} /> 483 + Add 484 + </button> 485 + )} 486 + </div> 487 + {loadError && <p className="text-caption text-error mb-2">{loadError}</p>} 488 + <ul className="space-y-1" aria-label="Member list"> 489 + {members.map((member) => ( 490 + <MemberRow 491 + key={member.did} 492 + member={member} 493 + profile={member.did in profiles ? (profiles[member.did] ?? null) : null} 494 + isMe={member.did === myDid} 495 + isManager={isManager} 496 + confirmingRemove={confirmingRemove === member.did} 497 + onRemove={() => handleRemove(member.did)} 498 + onRoleChange={(newRole) => handleRoleChange(member.did, newRole)} 499 + /> 500 + ))} 501 + </ul> 502 + </section> 503 + 504 + {/* Danger zone */} 505 + <section> 506 + <h2 className="text-error mb-3 text-sm font-semibold">Danger zone</h2> 507 + <div className="border-error/20 space-y-4 rounded-lg border p-4"> 508 + {/* Leave — non-owners only */} 509 + {!isOwner && ( 510 + <div> 511 + <p className="text-caption text-text-muted mb-2"> 512 + Leave this workspace. Your access will be revoked. 513 + </p> 514 + <button 515 + onClick={handleLeave} 516 + className="btn btn-confirm-danger btn-sm gap-1.5 rounded-lg text-xs" 517 + data-confirming={confirmingLeave || undefined} 518 + > 519 + <SignOutIcon size={13} /> 520 + {confirmingLeave ? "Click again to confirm" : "Leave workspace"} 521 + </button> 522 + </div> 523 + )} 524 + 525 + {/* Delete — owner only */} 526 + {isOwner && ( 527 + <div> 528 + <p className="text-caption text-text-muted mb-2"> 529 + Permanently delete this workspace and all its files. This cannot be undone. 530 + </p> 531 + {showDeleteConfirm ? ( 532 + <DestructiveConfirmation 533 + phrase={`I want to delete ${workspaceName} and all its data`} 534 + onConfirm={handleDeleteWorkspace} 535 + /> 536 + ) : ( 537 + <button 538 + onClick={() => setShowDeleteConfirm(true)} 539 + className="btn btn-ghost btn-sm gap-1.5 rounded-lg text-xs" 540 + > 541 + <TrashIcon size={13} /> 542 + Delete workspace 543 + </button> 544 + )} 545 + </div> 546 + )} 547 + </div> 548 + </section> 549 + </div> 550 + 551 + <AddMemberDialog ref={addMemberDialogRef} onConfirm={handleAddMember} /> 552 + </PanelShell> 553 + ); 554 + } 2 555 3 - export const Route = createFileRoute("/cabinet/workspace-settings/$rkey")({}); 556 + // --------------------------------------------------------------------------- 557 + // Member row 558 + // --------------------------------------------------------------------------- 559 + 560 + function MemberRow({ 561 + member, 562 + profile, 563 + isMe, 564 + isManager, 565 + confirmingRemove, 566 + onRemove, 567 + onRoleChange, 568 + }: { 569 + readonly member: KeyringMemberEntry; 570 + readonly profile: MemberProfile | null; 571 + readonly isMe: boolean; 572 + readonly isManager: boolean; 573 + readonly confirmingRemove: boolean; 574 + readonly onRemove: () => void; 575 + readonly onRoleChange: (role: WorkspaceRole) => void; 576 + }) { 577 + const RoleIcon = ROLE_ICON[member.role]; 578 + const displayName = profile?.handle ?? member.did; 579 + const canRemove = isManager && !isMe; 580 + const canChangeRole = isManager && !isMe; 581 + 582 + return ( 583 + <li className="flex items-center gap-3 rounded-lg px-3 py-2.5"> 584 + {profile?.avatarUrl ? ( 585 + <img src={profile.avatarUrl} alt="" className="size-8 shrink-0 rounded-full object-cover" /> 586 + ) : ( 587 + <div className="bg-accent text-primary text-micro flex size-8 shrink-0 items-center justify-center rounded-full font-semibold"> 588 + {displayName[0].toUpperCase()} 589 + </div> 590 + )} 591 + <div className="flex min-w-0 flex-1 flex-col"> 592 + <span className="text-ui text-base-content truncate"> 593 + {displayName} 594 + {isMe && <span className="text-text-faint ml-1.5 text-[10px]">(you)</span>} 595 + </span> 596 + {canChangeRole ? ( 597 + <select 598 + value={member.role} 599 + onChange={(e) => onRoleChange(e.target.value as WorkspaceRole)} 600 + className="select select-xs text-caption text-base-content bg-base-100 w-24 rounded-lg" 601 + aria-label={`Role for ${displayName}`} 602 + > 603 + {ROLE_OPTIONS.map((r) => ( 604 + <option key={r} value={r}> 605 + {r.charAt(0).toUpperCase() + r.slice(1)} 606 + </option> 607 + ))} 608 + </select> 609 + ) : ( 610 + <span className="text-caption text-text-faint flex items-center gap-1"> 611 + <RoleIcon size={10} /> 612 + {member.role.charAt(0).toUpperCase() + member.role.slice(1)} 613 + </span> 614 + )} 615 + </div> 616 + {canRemove && ( 617 + <button 618 + onClick={onRemove} 619 + className="btn btn-confirm-danger btn-xs rounded-lg" 620 + data-confirming={confirmingRemove || undefined} 621 + title={confirmingRemove ? "Click again to confirm" : "Remove member"} 622 + aria-label={`Remove ${displayName}`} 623 + > 624 + <UserMinusIcon size={14} /> 625 + {confirmingRemove && <span className="text-[10px]">confirm?</span>} 626 + </button> 627 + )} 628 + </li> 629 + ); 630 + } 631 + 632 + export const Route = createFileRoute("/cabinet/workspace-settings/$rkey")({ 633 + component: WorkspaceSettingsPage, 634 + });
+31
apps/web/src/stores/workspace.ts
··· 2 2 // 3 3 // Module-level promise dedup prevents StrictMode double-effect from 4 4 // sending concurrent `&mut self` borrows into WASM (RefCell panic). 5 + // 6 + // Real-time refresh: the WASM SSE consumer dispatches an 7 + // `opake:workspace-updated` CustomEvent on the `window` whenever the 8 + // appview broadcasts a keyring record change (this device's writes, 9 + // peer writes, other-device writes). We listen and re-fetch. A 10 + // visibility listener catches edge cases where SSE is disconnected or 11 + // the page was hidden across events. 5 12 6 13 import { create } from "zustand"; 7 14 import { immer } from "zustand/middleware/immer"; ··· 134 141 }, 135 142 })), 136 143 ); 144 + 145 + // SSE-driven refresh: the WASM consumer fires this CustomEvent on any 146 + // keyring-record-level change. Only relevant after the initial load 147 + // (so we don't fetch before login). `loadWorkspaces` dedups concurrent 148 + // calls, so event bursts coalesce naturally. 149 + if (typeof window !== "undefined") { 150 + window.addEventListener("opake:workspace-updated", () => { 151 + const state = useWorkspaceStore.getState(); 152 + if (!state.loaded) return; 153 + void state.loadWorkspaces(); 154 + }); 155 + } 156 + 157 + // Visibility fallback: covers the case where SSE was disconnected 158 + // while the page was hidden (browser backgrounding, sleep, etc). 159 + // Same dedup semantics as the SSE path. 160 + if (typeof document !== "undefined") { 161 + document.addEventListener("visibilitychange", () => { 162 + if (document.visibilityState !== "visible") return; 163 + const state = useWorkspaceStore.getState(); 164 + if (!state.loaded) return; 165 + void state.loadWorkspaces(); 166 + }); 167 + }
+3
crates/opake-wasm/Cargo.toml
··· 20 20 console_log = { version = "1", features = ["color"] } 21 21 console_error_panic_hook = "0.1" 22 22 23 + # Window + CustomEvent for SSE → JS notification dispatch. 24 + web-sys = { version = "0.3", features = ["Window", "CustomEvent", "CustomEventInit", "Event", "EventTarget"] } 25 + 23 26 # Async Mutex for the shared WasmOpake — RefCell panics on concurrent 24 27 # borrow across await points; Mutex queues instead. 25 28 futures-util = { workspace = true }
+49
crates/opake-wasm/src/sse_wasm.rs
··· 277 277 log::warn!("[sse] tree_keeper apply failed: {e}"); 278 278 } 279 279 } 280 + 281 + // Notify JS of keyring-record-level changes so stores 282 + // tracking workspace metadata (name, icon, members) 283 + // can re-fetch against the now-indexed appview. Fires 284 + // only for direct record events — proposals flow 285 + // through the owner's apply step, which emits a 286 + // subsequent KeyringUpsert. 287 + if is_keyring_record_event(&event) { 288 + if let Some(uri) = event.keyring_uri() { 289 + dispatch_workspace_updated(uri); 290 + } 291 + } 280 292 } 281 293 // Task exited — clear the flag in case we broke on a 282 294 // transport error rather than an explicit stop, so a ··· 435 447 } 436 448 }); 437 449 let _ = wasm_bindgen_futures::JsFuture::from(promise).await; 450 + } 451 + 452 + /// True for keyring-record-level events — i.e. direct writes/deletes of 453 + /// the keyring record, not proposal variants. These are the signals the 454 + /// workspace list wants (name/icon/member changes are visible on the 455 + /// keyring record itself). 456 + fn is_keyring_record_event(event: &opake_core::sse::events::SseEvent) -> bool { 457 + use opake_core::sse::events::SseEvent; 458 + matches!( 459 + event, 460 + SseEvent::KeyringUpsert(_) | SseEvent::KeyringDelete(_) 461 + ) 462 + } 463 + 464 + /// Dispatch `opake:workspace-updated` as a window CustomEvent so JS 465 + /// stores can reload the affected workspace. The detail payload is the 466 + /// keyring URI as a string. Soft-fails on any web-sys error — this 467 + /// notification is best-effort and losing one means users wait until 468 + /// the next full reload, which is acceptable. 469 + fn dispatch_workspace_updated(keyring_uri: &str) { 470 + let Some(window) = web_sys::window() else { 471 + return; 472 + }; 473 + let detail = JsValue::from_str(keyring_uri); 474 + let init = web_sys::CustomEventInit::new(); 475 + init.set_detail(&detail); 476 + let event = 477 + match web_sys::CustomEvent::new_with_event_init_dict("opake:workspace-updated", &init) { 478 + Ok(e) => e, 479 + Err(e) => { 480 + log::warn!("[sse] failed to construct CustomEvent: {e:?}"); 481 + return; 482 + } 483 + }; 484 + if let Err(e) = window.dispatch_event(&event) { 485 + log::warn!("[sse] failed to dispatch workspace-updated: {e:?}"); 486 + } 438 487 } 439 488 440 489 /// Wrap a JS function as a `WatcherCallback` that builds a snapshot on
+18
packages/opake-sdk/src/opake.ts
··· 553 553 } 554 554 555 555 /** 556 + * Unwrap the group (content) key for a workspace using the current 557 + * identity's private key. Required before calling any member-management 558 + * method that takes a `key: Uint8Array` parameter. 559 + * 560 + * Note: returning the key to JS is a pragmatic escape hatch for the 561 + * web management UI — the proper path keeps the key inside WASM. Do 562 + * not persist, log, or transmit the returned bytes. 563 + * 564 + * @param members - Raw keyring member records (from `listWorkspaceMembers`). 565 + * @returns The 32-byte group key as a Uint8Array. 566 + */ 567 + @wrapWasmErrors 568 + @withTokenGuard 569 + unwrapGroupKey(members: readonly WorkspaceMember[]): Promise<Uint8Array> { 570 + return this.requireContext().unwrapGroupKey(members); 571 + } 572 + 573 + /** 556 574 * Add a member to a workspace. 557 575 */ 558 576 @wrapWasmErrors