An encrypted personal cloud built on the AT Protocol.

Convert cabinet to URL-based routing with extracted UI components

Replace state-driven panel navigation with TanStack Router file-based
routes. Directory rkeys become URL path segments so browsing is
shareable and back-button-friendly (/cabinet/files/abc/def/ghi).

- Add splat route for nested directory navigation
- Extract PanelShell, Breadcrumbs, SegmentedToggle, DropdownMenu
- Convert Sidebar and TopBar to use <Link> instead of callbacks
- Add documents store with WASM directory tree + lazy decryption
- Add WASM buildDirectoryTree with async document name resolution
- Rename kebab-case files to camelCase/PascalCase for consistency

+2639 -1432
+1
.gitignore
··· 12 12 .opencode/ 13 13 driver-key.pub 14 14 .vscode/ 15 + .vite/ 15 16 16 17 # === Crosslink managed (do not edit between markers) === 17 18 # .crosslink/ — machine-local state (never commit)
+2
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add web file browser with tag filtering [#145](https://issues.opake.app/issues/145.html) 16 + - Add URL-based routing for cabinet directory navigation [#247](https://issues.opake.app/issues/247.html) 15 17 - Add ESLint, Prettier, and Immer to web frontend [#207](https://issues.opake.app/issues/207.html) 16 18 - Refactor auth store into session + identity state, non-blocking boot [#209](https://issues.opake.app/issues/209.html) 17 19 - Add destructive action confirmation component with ghost-text typing [#218](https://issues.opake.app/issues/218.html)
+59 -16
crates/opake-core/src/directories/tree.rs
··· 71 71 } 72 72 73 73 impl DirectoryTree { 74 - /// Load the directory hierarchy from the PDS. 74 + /// Build a tree from pre-fetched directory records. 75 75 /// 76 - /// Makes one paginated API call (all directories). Documents are NOT 77 - /// loaded — callers provide document names separately via `resolve()`. 78 - /// The root is detected from the listing by its rkey ("self"). 79 - pub async fn load(client: &mut XrpcClient<impl Transport>) -> Result<Self, Error> { 80 - let dir_entries: Vec<(String, DirectoryInfo)> = 81 - list_collection(client, DIRECTORY_COLLECTION, |uri, dir: Directory| { 76 + /// Accepts (AT-URI, Directory) pairs — the same shape returned by 77 + /// `listRecords`. The root is detected by rkey `"self"`. 78 + pub fn from_records(records: impl IntoIterator<Item = (String, Directory)>) -> Self { 79 + let directories: HashMap<String, DirectoryInfo> = records 80 + .into_iter() 81 + .map(|(uri, dir)| { 82 82 ( 83 - uri.to_owned(), 83 + uri, 84 84 DirectoryInfo { 85 85 name: String::new(), 86 86 encryption: dir.encryption, ··· 89 89 }, 90 90 ) 91 91 }) 92 - .await?; 93 - 94 - let directories: HashMap<String, DirectoryInfo> = dir_entries.into_iter().collect(); 92 + .collect(); 95 93 96 - // Find root by rkey — it's the singleton at rkey "self". 97 94 let root_uri = directories 98 95 .keys() 99 96 .find(|uri| { ··· 104 101 .cloned(); 105 102 106 103 debug!( 107 - "loaded tree: {} directories, root={}", 104 + "built tree: {} directories, root={}", 108 105 directories.len(), 109 106 root_uri.as_deref().unwrap_or("none"), 110 107 ); 111 108 112 - Ok(Self { 109 + Self { 113 110 directories, 114 111 root_uri, 115 - }) 112 + } 113 + } 114 + 115 + /// Load the directory hierarchy from the PDS. 116 + /// 117 + /// Makes one paginated API call (all directories). Documents are NOT 118 + /// loaded — callers provide document names separately via `resolve()`. 119 + /// The root is detected from the listing by its rkey ("self"). 120 + pub async fn load(client: &mut XrpcClient<impl Transport>) -> Result<Self, Error> { 121 + let dir_entries: Vec<(String, Directory)> = 122 + list_collection(client, DIRECTORY_COLLECTION, |uri, dir: Directory| { 123 + (uri.to_owned(), dir) 124 + }) 125 + .await?; 126 + 127 + Ok(Self::from_records(dir_entries)) 116 128 } 117 129 118 130 /// Decrypt all directory names in-place. ··· 336 348 } 337 349 } 338 350 351 + // ----------------------------------------------------------------------- 352 + // Public getters (used by WASM handle + CLI) 353 + // ----------------------------------------------------------------------- 354 + 355 + pub fn root_uri(&self) -> Option<&str> { 356 + self.root_uri.as_deref() 357 + } 358 + 359 + /// Returns the child entry URIs for a directory, or None if the URI 360 + /// is not a known directory. 361 + pub fn entries_for(&self, uri: &str) -> Option<&[String]> { 362 + self.directories 363 + .get(uri) 364 + .map(|info| info.entries.as_slice()) 365 + } 366 + 367 + /// Returns the decrypted name for a directory URI. 368 + pub fn directory_name(&self, uri: &str) -> Option<&str> { 369 + self.directories.get(uri).map(|info| info.name.as_str()) 370 + } 371 + 372 + /// Whether the given URI is a known directory in this tree. 373 + pub fn is_directory(&self, uri: &str) -> bool { 374 + self.directories.contains_key(uri) 375 + } 376 + 377 + /// Iterate over all directory URIs in the tree. 378 + pub fn all_directory_uris(&self) -> impl Iterator<Item = &str> { 379 + self.directories.keys().map(String::as_str) 380 + } 381 + 339 382 /// Count descendant documents and directories under a directory URI. 340 383 pub fn count_descendants(&self, uri: &str) -> (usize, usize) { 341 384 let mut documents = 0usize; ··· 544 587 } 545 588 546 589 /// Scan all directories to find which one contains the given URI as an entry. 547 - fn find_parent(&self, child_uri: &str) -> Option<String> { 590 + pub fn find_parent(&self, child_uri: &str) -> Option<String> { 548 591 for (dir_uri, info) in &self.directories { 549 592 if info.entries.iter().any(|e| e == child_uri) { 550 593 return Some(dir_uri.clone());
+76
crates/opake-core/src/directories/tree_tests.rs
··· 388 388 let descendants = tree.collect_descendants("at://did:plc:test/app.opake.directory/empty"); 389 389 assert!(descendants.is_empty()); 390 390 } 391 + 392 + // -- from_records -- 393 + 394 + #[test] 395 + fn from_records_detects_root() { 396 + let (_, private_key) = test_keypair(); 397 + let records = vec![ 398 + ( 399 + ROOT_URI.to_owned(), 400 + dummy_directory_with_entries("/", vec![DIR_PHOTOS_URI.into()]), 401 + ), 402 + ( 403 + DIR_PHOTOS_URI.to_owned(), 404 + dummy_directory_with_entries("Photos", vec![]), 405 + ), 406 + ]; 407 + 408 + let mut tree = DirectoryTree::from_records(records); 409 + tree.decrypt_names(TEST_DID, &private_key); 410 + 411 + assert_eq!(tree.root_uri(), Some(ROOT_URI)); 412 + assert_eq!(tree.directory_name(ROOT_URI), Some("/")); 413 + assert_eq!(tree.directory_name(DIR_PHOTOS_URI), Some("Photos")); 414 + } 415 + 416 + #[test] 417 + fn from_records_empty() { 418 + let tree = DirectoryTree::from_records(std::iter::empty()); 419 + assert!(tree.root_uri().is_none()); 420 + } 421 + 422 + // -- public getters -- 423 + 424 + #[tokio::test] 425 + async fn getters_return_expected_values() { 426 + let mock = MockTransport::new(); 427 + let tree = load_simple_tree(&mock).await; 428 + 429 + // root_uri 430 + assert_eq!(tree.root_uri(), Some(ROOT_URI)); 431 + 432 + // entries_for 433 + let root_entries = tree.entries_for(ROOT_URI).unwrap(); 434 + assert!(root_entries.contains(&DIR_PHOTOS_URI.to_owned())); 435 + assert!(root_entries.contains(&DOC_NOTES_URI.to_owned())); 436 + 437 + let photos_entries = tree.entries_for(DIR_PHOTOS_URI).unwrap(); 438 + assert_eq!(photos_entries, &[DOC_BEACH_URI.to_owned()]); 439 + 440 + assert!(tree.entries_for("at://nonexistent").is_none()); 441 + 442 + // directory_name 443 + assert_eq!(tree.directory_name(ROOT_URI), Some("/")); 444 + assert_eq!(tree.directory_name(DIR_PHOTOS_URI), Some("Photos")); 445 + assert!(tree.directory_name(DOC_BEACH_URI).is_none()); 446 + 447 + // is_directory 448 + assert!(tree.is_directory(ROOT_URI)); 449 + assert!(tree.is_directory(DIR_PHOTOS_URI)); 450 + assert!(!tree.is_directory(DOC_BEACH_URI)); 451 + 452 + // all_directory_uris 453 + let all_uris: Vec<&str> = tree.all_directory_uris().collect(); 454 + assert_eq!(all_uris.len(), 2); 455 + assert!(all_uris.contains(&ROOT_URI)); 456 + assert!(all_uris.contains(&DIR_PHOTOS_URI)); 457 + 458 + // find_parent 459 + assert_eq!(tree.find_parent(DIR_PHOTOS_URI).as_deref(), Some(ROOT_URI)); 460 + assert_eq!(tree.find_parent(DOC_NOTES_URI).as_deref(), Some(ROOT_URI)); 461 + assert_eq!( 462 + tree.find_parent(DOC_BEACH_URI).as_deref(), 463 + Some(DIR_PHOTOS_URI) 464 + ); 465 + assert!(tree.find_parent(ROOT_URI).is_none()); 466 + }
+149 -2
crates/opake-wasm/src/lib.rs
··· 1 + use std::collections::HashMap; 2 + 1 3 use opake_core::client::dpop::DpopKeyPair; 2 4 use opake_core::client::oauth_discovery::generate_pkce; 3 5 use opake_core::crypto::{ 4 6 ContentKey, DirectoryMetadata, DocumentMetadata, EncryptedPayload, GrantMetadata, 5 7 KeyringMetadata, OsRng, X25519PrivateKey, X25519PublicKey, 6 8 }; 7 - use opake_core::records::WrappedKey; 9 + use opake_core::directories::{DirectoryTree, EntryKind}; 10 + use opake_core::records::{Directory, WrappedKey}; 8 11 use opake_core::storage::Identity; 9 - use serde::Serialize; 12 + use serde::{Deserialize, Serialize}; 10 13 use wasm_bindgen::prelude::*; 11 14 12 15 #[wasm_bindgen(start)] ··· 394 397 .map_err(|e| JsError::new(&e.to_string()))?; 395 398 serde_wasm_bindgen::to_value(&metadata).map_err(|e| JsError::new(&e.to_string())) 396 399 } 400 + 401 + // --------------------------------------------------------------------------- 402 + // DirectoryTree handle (stateful WASM export) 403 + // --------------------------------------------------------------------------- 404 + 405 + #[derive(Deserialize)] 406 + struct DirectoryRecordInput { 407 + uri: String, 408 + value: Directory, 409 + } 410 + 411 + #[derive(Serialize)] 412 + struct DirectorySnapshotEntry { 413 + name: String, 414 + entries: Vec<String>, 415 + } 416 + 417 + #[derive(Serialize)] 418 + #[serde(rename_all = "camelCase")] 419 + struct DirectoryTreeSnapshot { 420 + root_uri: Option<String>, 421 + directories: HashMap<String, DirectorySnapshotEntry>, 422 + } 423 + 424 + #[derive(Serialize)] 425 + struct DescendantCount { 426 + documents: usize, 427 + directories: usize, 428 + } 429 + 430 + #[derive(Serialize)] 431 + struct DescendantEntry { 432 + uri: String, 433 + kind: String, 434 + } 435 + 436 + #[wasm_bindgen] 437 + pub struct DirectoryTreeHandle { 438 + inner: DirectoryTree, 439 + } 440 + 441 + #[wasm_bindgen] 442 + impl DirectoryTreeHandle { 443 + /// Build a tree from PDS directory records, decrypt all directory names. 444 + /// 445 + /// `records_js` is `Array<{ uri: string, value: DirectoryRecord }>`. 446 + #[wasm_bindgen(constructor)] 447 + pub fn new( 448 + records_js: JsValue, 449 + did: &str, 450 + private_key: &[u8], 451 + ) -> Result<DirectoryTreeHandle, JsError> { 452 + let inputs: Vec<DirectoryRecordInput> = 453 + serde_wasm_bindgen::from_value(records_js).map_err(|e| JsError::new(&e.to_string()))?; 454 + 455 + let records = inputs.into_iter().map(|r| (r.uri, r.value)); 456 + let mut tree = DirectoryTree::from_records(records); 457 + 458 + let priv_key: &X25519PrivateKey = private_key 459 + .try_into() 460 + .map_err(|_| JsError::new("private key must be exactly 32 bytes"))?; 461 + tree.decrypt_names(did, priv_key); 462 + 463 + Ok(Self { inner: tree }) 464 + } 465 + 466 + /// Bulk-transfer the entire tree state to JS as a single object. 467 + #[wasm_bindgen(js_name = snapshot)] 468 + pub fn snapshot(&self) -> Result<JsValue, JsError> { 469 + let mut directories = HashMap::new(); 470 + for uri in self.inner.all_directory_uris() { 471 + let name = self.inner.directory_name(uri).unwrap_or("?").to_owned(); 472 + let entries = self 473 + .inner 474 + .entries_for(uri) 475 + .map(|e| e.to_vec()) 476 + .unwrap_or_default(); 477 + directories.insert(uri.to_owned(), DirectorySnapshotEntry { name, entries }); 478 + } 479 + 480 + let snap = DirectoryTreeSnapshot { 481 + root_uri: self.inner.root_uri().map(str::to_owned), 482 + directories, 483 + }; 484 + let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); 485 + snap.serialize(&serializer) 486 + .map_err(|e| JsError::new(&e.to_string())) 487 + } 488 + 489 + #[wasm_bindgen(js_name = rootUri)] 490 + pub fn root_uri(&self) -> Option<String> { 491 + self.inner.root_uri().map(str::to_owned) 492 + } 493 + 494 + #[wasm_bindgen(js_name = entriesFor)] 495 + pub fn entries_for(&self, uri: &str) -> JsValue { 496 + match self.inner.entries_for(uri) { 497 + Some(entries) => serde_wasm_bindgen::to_value(entries).unwrap_or(JsValue::NULL), 498 + None => JsValue::NULL, 499 + } 500 + } 501 + 502 + #[wasm_bindgen(js_name = directoryName)] 503 + pub fn directory_name(&self, uri: &str) -> Option<String> { 504 + self.inner.directory_name(uri).map(str::to_owned) 505 + } 506 + 507 + #[wasm_bindgen(js_name = isDirectory)] 508 + pub fn is_directory(&self, uri: &str) -> bool { 509 + self.inner.is_directory(uri) 510 + } 511 + 512 + #[wasm_bindgen(js_name = findParent)] 513 + pub fn find_parent(&self, uri: &str) -> Option<String> { 514 + self.inner.find_parent(uri) 515 + } 516 + 517 + #[wasm_bindgen(js_name = countDescendants)] 518 + pub fn count_descendants(&self, uri: &str) -> JsValue { 519 + let (documents, directories) = self.inner.count_descendants(uri); 520 + serde_wasm_bindgen::to_value(&DescendantCount { 521 + documents, 522 + directories, 523 + }) 524 + .unwrap_or(JsValue::NULL) 525 + } 526 + 527 + #[wasm_bindgen(js_name = collectDescendants)] 528 + pub fn collect_descendants(&self, uri: &str) -> JsValue { 529 + let descendants: Vec<DescendantEntry> = self 530 + .inner 531 + .collect_descendants(uri) 532 + .into_iter() 533 + .map(|(uri, kind)| DescendantEntry { 534 + uri, 535 + kind: match kind { 536 + EntryKind::Document => "document".into(), 537 + EntryKind::Directory => "directory".into(), 538 + }, 539 + }) 540 + .collect(); 541 + serde_wasm_bindgen::to_value(&descendants).unwrap_or(JsValue::NULL) 542 + } 543 + }
+36
web/src/components/DropdownMenu.tsx
··· 1 + import type { ComponentType, ReactNode } from "react"; 2 + 3 + interface DropdownMenuItem { 4 + readonly icon: ComponentType<{ readonly size: number; readonly className?: string }>; 5 + readonly label: string; 6 + readonly onClick?: () => void; 7 + } 8 + 9 + interface DropdownMenuProps { 10 + readonly trigger: ReactNode; 11 + readonly items: readonly DropdownMenuItem[]; 12 + } 13 + 14 + export function DropdownMenu({ trigger, items }: DropdownMenuProps) { 15 + return ( 16 + <details className="dropdown dropdown-end"> 17 + <summary className="btn btn-neutral btn-sm gap-1.5 rounded-lg text-xs">{trigger}</summary> 18 + <ul className="menu dropdown-content border-base-300/50 bg-base-100 shadow-panel-lg z-50 w-42 rounded-xl border p-1"> 19 + {items.map(({ icon: Icon, label, onClick }) => ( 20 + <li key={label}> 21 + <button 22 + onClick={(e) => { 23 + e.currentTarget.closest("details")?.removeAttribute("open"); 24 + onClick?.(); 25 + }} 26 + className="text-secondary gap-2.5 text-xs" 27 + > 28 + <Icon size={13} className="text-text-muted" /> 29 + {label} 30 + </button> 31 + </li> 32 + ))} 33 + </ul> 34 + </details> 35 + ); 36 + }
+1 -5
web/src/components/OpakeLogo.tsx
··· 83 83 return ( 84 84 <div className="flex items-center gap-2.5"> 85 85 <div className="relative shrink-0" style={{ width: wrap, height: wrap }}> 86 - <div 87 - className="absolute top-0 left-0 rounded-sm" 88 - ref={square1Ref} 89 - style={square1Style} 90 - /> 86 + <div className="absolute top-0 left-0 rounded-sm" ref={square1Ref} style={square1Style} /> 91 87 <div 92 88 className="absolute right-0 bottom-0 rounded-sm" 93 89 ref={square2Ref}
+36
web/src/components/SegmentedToggle.tsx
··· 1 + import type { ComponentType } from "react"; 2 + 3 + interface ToggleOption<T extends string> { 4 + readonly value: T; 5 + readonly icon: ComponentType<{ readonly size: number }>; 6 + } 7 + 8 + interface SegmentedToggleProps<T extends string> { 9 + readonly options: readonly ToggleOption<T>[]; 10 + readonly value: T; 11 + readonly onChange: (value: T) => void; 12 + } 13 + 14 + export function SegmentedToggle<T extends string>({ 15 + options, 16 + value, 17 + onChange, 18 + }: SegmentedToggleProps<T>) { 19 + return ( 20 + <div className="join bg-primary/10 rounded-lg p-0.5"> 21 + {options.map((option) => ( 22 + <button 23 + key={option.value} 24 + onClick={() => onChange(option.value)} 25 + className={`join-item btn btn-xs rounded-md border-0 ${ 26 + value === option.value 27 + ? "bg-base-100 text-secondary shadow-panel-sm" 28 + : "text-text-faint bg-transparent" 29 + }`} 30 + > 31 + <option.icon size={13} /> 32 + </button> 33 + ))} 34 + </div> 35 + ); 36 + }
+25
web/src/components/cabinet/Breadcrumbs.tsx
··· 1 + import type { ReactNode } from "react"; 2 + 3 + export function Breadcrumbs({ children }: { readonly children: ReactNode }) { 4 + return ( 5 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 6 + <ul>{children}</ul> 7 + </div> 8 + ); 9 + } 10 + 11 + export function BreadcrumbActive({ children }: { readonly children: ReactNode }) { 12 + return ( 13 + <li> 14 + <span className="text-base-content font-medium">{children}</span> 15 + </li> 16 + ); 17 + } 18 + 19 + export function BreadcrumbSkeleton() { 20 + return ( 21 + <li> 22 + <span className="skeleton h-4 w-24 rounded" /> 23 + </li> 24 + ); 25 + }
+23 -17
web/src/components/cabinet/FileGridCard.tsx
··· 1 - import { LockIcon } from "@phosphor-icons/react" 2 - import { StatusBadge } from "./StatusBadge" 3 - import { fileIconElement, fileIconColors } from "./file-icons" 4 - import type { FileItem } from "./types" 1 + import { LockIcon } from "@phosphor-icons/react"; 2 + import { StatusBadge } from "./StatusBadge"; 3 + import { fileIconElement, fileIconColors } from "./FileIcons"; 4 + import type { FileItem } from "./types"; 5 5 6 6 interface FileGridCardProps { 7 - item: FileItem 8 - onClick: () => void 7 + readonly item: FileItem; 8 + readonly onClick: () => void; 9 9 } 10 10 11 - export function FileGridCard({ item, onClick }: Readonly<FileGridCardProps>) { 12 - const { bg, text } = fileIconColors(item) 13 - const isFolder = item.kind === "folder" 11 + export function FileGridCard({ item, onClick }: FileGridCardProps) { 12 + const { bg, text } = fileIconColors(item); 13 + const isFolder = item.kind === "folder"; 14 14 15 15 return ( 16 16 <div ··· 19 19 isFolder 20 20 ? (e) => { 21 21 if (e.key === "Enter" || e.key === " ") { 22 - e.preventDefault() 23 - onClick() 22 + e.preventDefault(); 23 + onClick(); 24 24 } 25 25 } 26 26 : undefined 27 27 } 28 28 role={isFolder ? "button" : "article"} 29 29 tabIndex={isFolder ? 0 : undefined} 30 - aria-label={`${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}`} 30 + aria-label={ 31 + item.decrypted 32 + ? `${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}` 33 + : "Decrypting…" 34 + } 31 35 className={`card border-base-300/50 bg-base-100 shadow-panel-sm hover:border-base-300 hover:shadow-panel-md border p-4 transition-all ${ 32 36 isFolder ? "cursor-pointer" : "" 33 37 }`} 34 38 > 35 39 <div className="mb-3 flex items-start justify-between"> 36 - <div 37 - className={`flex size-9.5 items-center justify-center rounded-[10px] ${bg} ${text}`} 38 - > 40 + <div className={`flex size-9.5 items-center justify-center rounded-[10px] ${bg} ${text}`}> 39 41 {fileIconElement(item, 17)} 40 42 </div> 41 43 <LockIcon size={11} className="text-text-faint" /> ··· 47 49 <LockIcon size={13} className="text-text-faint relative z-10" /> 48 50 </div> 49 51 50 - <div className="text-base-content mb-1.5 truncate text-xs">{item.name}</div> 52 + {item.decrypted ? ( 53 + <div className="text-base-content mb-1.5 truncate text-xs">{item.name}</div> 54 + ) : ( 55 + <div className="skeleton mb-1.5 h-4 w-24 rounded" /> 56 + )} 51 57 <div className="flex items-center justify-between"> 52 58 <span className="text-caption text-text-faint">{item.modified}</span> 53 59 <StatusBadge status={item.status} /> 54 60 </div> 55 61 </div> 56 - ) 62 + ); 57 63 }
+37 -29
web/src/components/cabinet/FileListRow.tsx
··· 1 - import { StarIcon, CaretRightIcon } from "@phosphor-icons/react" 2 - import { StatusBadge } from "./StatusBadge" 3 - import { fileIconElement, fileIconColors } from "./file-icons" 4 - import type { FileItem } from "./types" 1 + import { CaretRightIcon } from "@phosphor-icons/react"; 2 + import { StatusBadge } from "./StatusBadge"; 3 + import { fileIconElement, fileIconColors } from "./FileIcons"; 4 + import type { FileItem } from "./types"; 5 5 6 6 interface FileListRowProps { 7 - item: FileItem 8 - onClick: () => void 9 - onStar: () => void 7 + readonly item: FileItem; 8 + readonly onClick: () => void; 10 9 } 11 10 12 - // eslint-disable-next-line sonarjs/cognitive-complexity -- single-component render with conditional attributes; splitting would fragment the layout 13 - export function FileListRow({ item, onClick, onStar }: Readonly<FileListRowProps>) { 14 - const { bg, text } = fileIconColors(item) 15 - const isFolder = item.kind === "folder" 11 + export function FileListRow({ item, onClick }: FileListRowProps) { 12 + const { bg, text } = fileIconColors(item); 13 + const isFolder = item.kind === "folder"; 16 14 17 15 return ( 18 16 <div ··· 21 19 isFolder 22 20 ? (e) => { 23 21 if (e.key === "Enter" || e.key === " ") { 24 - e.preventDefault() 25 - onClick() 22 + e.preventDefault(); 23 + onClick(); 26 24 } 27 25 } 28 26 : undefined 29 27 } 30 28 role={isFolder ? "button" : "row"} 31 29 tabIndex={isFolder ? 0 : undefined} 32 - aria-label={`${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}`} 30 + aria-label={ 31 + item.decrypted 32 + ? `${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}` 33 + : "Decrypting…" 34 + } 33 35 className={`hover:bg-bg-hover flex items-center gap-3 rounded-xl px-3 py-2.25 transition-colors ${ 34 36 isFolder ? "cursor-pointer" : "" 35 37 }`} ··· 41 43 42 44 {/* Name + meta */} 43 45 <div className="min-w-0 flex-1"> 44 - <div className="text-ui text-base-content truncate">{item.name}</div> 46 + {item.decrypted ? ( 47 + <div className="text-ui text-base-content truncate">{item.name}</div> 48 + ) : ( 49 + <div className="skeleton h-4 w-36 rounded" /> 50 + )} 45 51 <div className="text-caption text-text-faint mt-0.5 flex items-center gap-1.5"> 46 52 <span>{item.modified}</span> 47 - {item.size && ( 53 + {item.decrypted && item.size && ( 48 54 <> 49 55 <span>·</span> 50 56 <span>{item.size}</span> ··· 59 65 </div> 60 66 </div> 61 67 68 + {/* Tags */} 69 + {item.decrypted && item.tags.length > 0 && ( 70 + <div className="flex shrink-0 items-center gap-1"> 71 + {item.tags.slice(0, 3).map((tag) => ( 72 + <span 73 + key={tag} 74 + className="badge badge-xs badge-ghost text-text-faint border-base-300/50 border" 75 + > 76 + {tag} 77 + </span> 78 + ))} 79 + </div> 80 + )} 81 + 62 82 {/* Status + actions */} 63 83 <div className="flex shrink-0 items-center gap-2"> 64 84 <StatusBadge status={item.status} /> 65 - <button 66 - onClick={(e) => { 67 - e.stopPropagation() 68 - onStar() 69 - }} 70 - aria-label={item.starred ? "Unstar" : "StarIcon"} 71 - className={`btn btn-ghost btn-xs p-0.5 ${ 72 - item.starred ? "text-warning" : "text-text-faint" 73 - }`} 74 - > 75 - <StarIcon size={13} weight={item.starred ? "fill" : "regular"} /> 76 - </button> 77 85 {isFolder && <CaretRightIcon size={13} className="text-text-faint" />} 78 86 </div> 79 87 </div> 80 - ) 88 + ); 81 89 }
+10 -181
web/src/components/cabinet/PanelContent.tsx
··· 1 - import { 2 - SparkleIcon, 3 - LockIcon, 4 - ShareNetworkIcon, 5 - GraphIcon, 6 - QuestionIcon, 7 - ArrowSquareOutIcon, 8 - UserIcon, 9 - ShieldCheckIcon, 10 - BellIcon, 11 - CaretRightIcon, 12 - TrashIcon, 13 - FolderIcon, 14 - } from "@phosphor-icons/react" 15 - import { FileListRow } from "./FileListRow" 16 - import { FileGridCard } from "./FileGridCard" 17 - import type { FileItem, Panel } from "./types" 18 - import { ROOT_ITEMS, SHARED_ITEMS, DOCUMENTS_ITEMS } from "./mock-data" 19 - 20 - const DOCS_SECTIONS = [ 21 - { 22 - id: "getting-started", 23 - title: "Getting Started", 24 - icon: SparkleIcon, 25 - desc: "Set up your cabinet, create your first encrypted file, and explore the interface.", 26 - }, 27 - { 28 - id: "encryption", 29 - title: "Encryption & Keys", 30 - icon: LockIcon, 31 - desc: "How end-to-end encryption works in Opake and how your keys are managed.", 32 - }, 33 - { 34 - id: "sharing", 35 - title: "Sharing & DIDs", 36 - icon: ShareNetworkIcon, 37 - desc: "Share files using decentralised identifiers without a central authority.", 38 - }, 39 - { 40 - id: "at-protocol", 41 - title: "AT Protocol", 42 - icon: GraphIcon, 43 - desc: "The open standard powering Opake — identity, data portability, and federation.", 44 - }, 45 - { 46 - id: "faq", 47 - title: "FAQ", 48 - icon: QuestionIcon, 49 - desc: "Common questions about privacy, security, and how Opake compares to alternatives.", 50 - }, 51 - ] 52 - 53 - const SETTINGS_SECTIONS = [ 54 - { label: "Account & Identity", desc: "DID: did:plc:7f2ab3c4d…8e91f0", icon: UserIcon }, 55 - { label: "Encryption Keys", desc: "Last rotated 14 days ago · Active", icon: LockIcon }, 56 - { label: "Sharing & Permissions", desc: "3 active collaborators", icon: ShareNetworkIcon }, 57 - { label: "Connected Devices", desc: "2 devices linked", icon: ShieldCheckIcon }, 58 - { label: "Notifications", desc: "Email & in-app alerts", icon: BellIcon }, 59 - ] 60 - 61 - const ALL_ITEMS = [...ROOT_ITEMS, ...SHARED_ITEMS, ...DOCUMENTS_ITEMS] 62 - 63 - function getItemsForPanel(panel: Panel, starredIds: ReadonlySet<string>): FileItem[] { 64 - const baseItems = (() => { 65 - switch (panel.type) { 66 - case "root": 67 - return ROOT_ITEMS 68 - case "shared": 69 - return SHARED_ITEMS 70 - case "starred": 71 - return ALL_ITEMS.filter((i) => starredIds.has(i.id)) 72 - case "encrypted": 73 - return ROOT_ITEMS.filter((i) => i.status === "private") 74 - case "folder": 75 - return panel.folderId === "f-documents" ? DOCUMENTS_ITEMS : ROOT_ITEMS.slice(5) 76 - default: 77 - return [] 78 - } 79 - })() 80 - 81 - return baseItems.map((item) => ({ 82 - ...item, 83 - starred: starredIds.has(item.id), 84 - })) 85 - } 1 + import { FolderIcon } from "@phosphor-icons/react"; 2 + import { FileListRow } from "./FileListRow"; 3 + import { FileGridCard } from "./FileGridCard"; 4 + import type { FileItem } from "./types"; 86 5 87 6 interface PanelContentProps { 88 - panel: Panel 89 - viewMode: "list" | "grid" 90 - starredIds: ReadonlySet<string> 91 - onOpen: (item: FileItem) => void 92 - onStar: (id: string) => void 7 + readonly items: readonly FileItem[]; 8 + readonly viewMode: "list" | "grid"; 9 + readonly onOpen: (item: FileItem) => void; 93 10 } 94 11 95 - export function PanelContent({ 96 - panel, 97 - viewMode, 98 - starredIds, 99 - onOpen, 100 - onStar, 101 - }: Readonly<PanelContentProps>) { 102 - // Docs 103 - if (panel.type === "docs") { 104 - return ( 105 - <div className="p-5"> 106 - <div className="mb-5"> 107 - <div className="text-ui text-base-content mb-1 font-medium">Documentation</div> 108 - <div className="text-text-muted text-xs"> 109 - Everything you need to get the most out of Opake. 110 - </div> 111 - </div> 112 - <div className="flex flex-col gap-2"> 113 - {DOCS_SECTIONS.map((s) => ( 114 - <div 115 - key={s.id} 116 - className="card card-bordered border-base-300/50 bg-base-100 cursor-pointer p-3.5" 117 - > 118 - <div className="bg-accent flex size-8 shrink-0 items-center justify-center rounded-lg"> 119 - <s.icon size={14} className="text-primary" /> 120 - </div> 121 - <div className="flex-1"> 122 - <div className="text-ui text-base-content mb-0.5 font-medium">{s.title}</div> 123 - <div className="text-caption text-text-muted leading-relaxed">{s.desc}</div> 124 - </div> 125 - <ArrowSquareOutIcon size={12} className="text-text-faint mt-0.5 shrink-0" /> 126 - </div> 127 - ))} 128 - </div> 129 - </div> 130 - ) 131 - } 132 - 133 - // Settings 134 - if (panel.type === "settings") { 135 - return ( 136 - <div className="p-5"> 137 - <div className="mb-5"> 138 - <div className="text-ui text-base-content mb-1 font-medium">Settings</div> 139 - <div className="text-text-muted text-xs">Manage your account, keys, and preferences.</div> 140 - </div> 141 - <div className="divider mt-0 mb-4" /> 142 - <div className="flex flex-col gap-1.5"> 143 - {SETTINGS_SECTIONS.map(({ label, desc, icon: Icon }) => ( 144 - <div 145 - key={label} 146 - className="card card-bordered border-base-300/50 bg-base-100 cursor-pointer p-3.5" 147 - > 148 - <div className="bg-bg-stone flex size-8 shrink-0 items-center justify-center rounded-lg"> 149 - <Icon size={14} className="text-text-muted" /> 150 - </div> 151 - <div className="flex-1"> 152 - <div className="text-ui text-base-content font-medium">{label}</div> 153 - <div className="text-caption text-text-muted">{desc}</div> 154 - </div> 155 - <CaretRightIcon size={13} className="text-text-faint" /> 156 - </div> 157 - ))} 158 - </div> 159 - </div> 160 - ) 161 - } 162 - 163 - // TrashIcon 164 - if (panel.type === "trash") { 165 - return ( 166 - <div className="hero py-16"> 167 - <div className="hero-content flex-col text-center"> 168 - <div className="bg-bg-stone flex size-13 items-center justify-center rounded-[14px]"> 169 - <TrashIcon size={22} className="text-text-faint" /> 170 - </div> 171 - <div className="text-ui text-text-muted">TrashIcon is empty</div> 172 - <div className="text-text-faint max-w-60 text-xs leading-relaxed"> 173 - Deleted files appear here for 30 days before permanent removal. 174 - </div> 175 - </div> 176 - </div> 177 - ) 178 - } 179 - 180 - // FileIcon browser (list / grid) 181 - const items = getItemsForPanel(panel, starredIds) 182 - 12 + export function PanelContent({ items, viewMode, onOpen }: PanelContentProps) { 183 13 if (items.length === 0) { 184 14 return ( 185 15 <div className="hero py-16"> ··· 190 20 <div className="text-ui text-text-muted">Nothing here yet</div> 191 21 </div> 192 22 </div> 193 - ) 23 + ); 194 24 } 195 25 196 26 return ( ··· 202 32 key={item.id} 203 33 item={item} 204 34 onClick={() => item.kind === "folder" && onOpen(item)} 205 - onStar={() => onStar(item.id)} 206 35 /> 207 36 ))} 208 37 </div> ··· 218 47 </div> 219 48 )} 220 49 </div> 221 - ) 50 + ); 222 51 }
+46
web/src/components/cabinet/PanelShell.tsx
··· 1 + import type { ReactNode } from "react"; 2 + import { ShieldCheckIcon } from "@phosphor-icons/react"; 3 + 4 + interface PanelShellProps { 5 + readonly depth: number; 6 + readonly breadcrumbs: ReactNode; 7 + readonly toolbar?: ReactNode; 8 + readonly footer: string; 9 + readonly children: ReactNode; 10 + } 11 + 12 + export function PanelShell({ depth, breadcrumbs, toolbar, footer, children }: PanelShellProps) { 13 + return ( 14 + <div className="relative flex-1 overflow-hidden p-5.5 pl-7"> 15 + {/* Ghost panels — filing cabinet depth */} 16 + {depth >= 3 && ( 17 + <div className="border-primary/15 bg-bg-ghost-1 absolute inset-y-5.5 right-5.5 left-7 z-1 -translate-x-2.5 -translate-y-2.5 rounded-2xl border" /> 18 + )} 19 + {depth >= 2 && ( 20 + <div className="border-base-300/50 bg-bg-ghost-2 shadow-panel-sm absolute inset-y-5.5 right-5.5 left-7 z-2 -translate-x-1.25 -translate-y-1.25 rounded-2xl border" /> 21 + )} 22 + 23 + {/* Active panel */} 24 + <div className="border-base-300/50 bg-base-100 shadow-panel-lg absolute inset-y-5.5 right-5.5 left-7 z-10 flex flex-col overflow-hidden rounded-2xl border"> 25 + {/* Panel header */} 26 + <div className="border-base-300/50 bg-base-100/70 flex shrink-0 items-center gap-2.5 border-b px-4 py-2.75"> 27 + {breadcrumbs} 28 + {toolbar && <div className="flex shrink-0 items-center gap-2">{toolbar}</div>} 29 + </div> 30 + 31 + {/* Panel body */} 32 + <div className="min-h-0 flex-1 overflow-y-auto">{children}</div> 33 + 34 + {/* Panel footer */} 35 + <div className="border-base-300/50 bg-base-100/60 flex shrink-0 items-center gap-2 border-t px-4 py-2.25"> 36 + <ShieldCheckIcon size={11} className="text-primary" /> 37 + <span className="text-caption text-text-faint">{footer}</span> 38 + <div className="flex-1" /> 39 + {depth > 1 && ( 40 + <span className="font-display text-ui text-text-faint italic">{depth} panels deep</span> 41 + )} 42 + </div> 43 + </div> 44 + </div> 45 + ); 46 + }
+1 -1
web/src/components/cabinet/PanelSkeleton.tsx
··· 5 5 <div key={i} className="skeleton h-12 w-full rounded-xl" /> 6 6 ))} 7 7 </div> 8 - ) 8 + ); 9 9 }
-266
web/src/components/cabinet/PanelStack.tsx
··· 1 - import { 2 - ListBulletsIcon, 3 - SquaresFourIcon, 4 - PlusIcon, 5 - XIcon, 6 - UploadSimpleIcon, 7 - FolderIcon, 8 - FileTextIcon, 9 - BookOpenIcon, 10 - ClockIcon, 11 - ShieldCheckIcon, 12 - UsersIcon, 13 - LockIcon, 14 - } from "@phosphor-icons/react" 15 - import { PanelContent } from "./PanelContent" 16 - import { PanelSkeleton } from "./PanelSkeleton" 17 - import { fileIconElement, fileIconColors } from "./file-icons" 18 - import { ROOT_ITEMS, SHARED_ITEMS } from "./mock-data" 19 - import type { FileItem, Panel } from "./types" 20 - import { panelKey } from "./types" 21 - 22 - const FILE_BROWSER_TYPES = new Set(["root", "folder", "shared", "starred", "encrypted"]) 23 - 24 - interface PanelStackProps { 25 - panels: Panel[] 26 - viewMode: "list" | "grid" 27 - starredIds: ReadonlySet<string> 28 - loading: boolean 29 - onViewModeChange: (mode: "list" | "grid") => void 30 - onOpenItem: (item: FileItem) => void 31 - onGoToPanel: (index: number) => void 32 - onClosePanel: () => void 33 - onStar: (id: string) => void 34 - } 35 - 36 - export function PanelStack({ 37 - panels, 38 - viewMode, 39 - starredIds, 40 - loading, 41 - onViewModeChange, 42 - onOpenItem, 43 - onGoToPanel, 44 - onClosePanel, 45 - onStar, 46 - }: Readonly<PanelStackProps>) { 47 - const currentPanel = panels[panels.length - 1] 48 - const depth = panels.length 49 - const isFileBrowser = FILE_BROWSER_TYPES.has(currentPanel.type) 50 - 51 - const footerText = (() => { 52 - switch (currentPanel.type) { 53 - case "root": 54 - return `${ROOT_ITEMS.length} items · All encrypted · AT Protocol` 55 - case "shared": 56 - return `${SHARED_ITEMS.length} shared items · Encrypted` 57 - case "starred": 58 - return `${starredIds.size} starred items` 59 - case "encrypted": 60 - return `${ROOT_ITEMS.filter((i) => i.status === "private").length} private items` 61 - case "folder": 62 - return `${currentPanel.itemCount ?? "–"} items · Encrypted` 63 - case "docs": 64 - return "Documentation · Opake" 65 - case "settings": 66 - return "Account settings" 67 - case "trash": 68 - return "TrashIcon · 30 day retention" 69 - } 70 - })() 71 - 72 - return ( 73 - <div className="relative flex-1 overflow-hidden p-5.5 pl-7"> 74 - {/* Ghost panels — filing cabinet depth */} 75 - {depth >= 3 && ( 76 - <div className="border-primary/15 bg-bg-ghost-1 absolute inset-y-5.5 right-5.5 left-7 z-1 -translate-x-2.5 -translate-y-2.5 rounded-2xl border" /> 77 - )} 78 - {depth >= 2 && ( 79 - <div className="border-base-300/50 bg-bg-ghost-2 shadow-panel-sm absolute inset-y-5.5 right-5.5 left-7 z-2 -translate-x-1.25 -translate-y-1.25 rounded-2xl border" /> 80 - )} 81 - 82 - {/* Active panel */} 83 - <div className="border-base-300/50 bg-base-100 shadow-panel-lg absolute inset-y-5.5 right-5.5 left-7 z-10 flex flex-col overflow-hidden rounded-2xl border"> 84 - {/* Panel header */} 85 - <div className="border-base-300/50 bg-base-100/70 flex shrink-0 items-center gap-2.5 border-b px-4 py-2.75"> 86 - {/* Breadcrumb */} 87 - <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 88 - <ul> 89 - {panels.map((panel, i) => ( 90 - <li key={panelKey(panel)}> 91 - <button 92 - onClick={() => onGoToPanel(i)} 93 - className={ 94 - i === panels.length - 1 ? "text-base-content font-medium" : "text-text-faint" 95 - } 96 - > 97 - {panel.title} 98 - </button> 99 - </li> 100 - ))} 101 - </ul> 102 - </div> 103 - 104 - {/* Toolbar */} 105 - <div className="flex shrink-0 items-center gap-2"> 106 - {/* View toggle */} 107 - {isFileBrowser && ( 108 - <div className="join bg-primary/10 rounded-lg p-0.5"> 109 - <button 110 - onClick={() => onViewModeChange("list")} 111 - className={`join-item btn btn-xs rounded-md border-0 ${ 112 - viewMode === "list" 113 - ? "bg-base-100 text-secondary shadow-panel-sm" 114 - : "text-text-faint bg-transparent" 115 - }`} 116 - > 117 - <ListBulletsIcon size={13} /> 118 - </button> 119 - <button 120 - onClick={() => onViewModeChange("grid")} 121 - className={`join-item btn btn-xs rounded-md border-0 ${ 122 - viewMode === "grid" 123 - ? "bg-base-100 text-secondary shadow-panel-sm" 124 - : "text-text-faint bg-transparent" 125 - }`} 126 - > 127 - <SquaresFourIcon size={13} /> 128 - </button> 129 - </div> 130 - )} 131 - 132 - {/* New button */} 133 - <details className="dropdown dropdown-end"> 134 - <summary className="btn btn-neutral btn-sm gap-1.5 rounded-lg text-xs"> 135 - <PlusIcon size={13} /> 136 - New 137 - </summary> 138 - <ul className="menu dropdown-content border-base-300/50 bg-base-100 shadow-panel-lg z-50 w-42 rounded-xl border p-1"> 139 - {[ 140 - { icon: UploadSimpleIcon, label: "Upload file" }, 141 - { icon: FolderIcon, label: "New folder" }, 142 - { icon: FileTextIcon, label: "New document" }, 143 - { icon: BookOpenIcon, label: "New note" }, 144 - ].map(({ icon: Icon, label }) => ( 145 - <li key={label}> 146 - <button 147 - onClick={(e) => { 148 - e.currentTarget.closest("details")?.removeAttribute("open") 149 - }} 150 - className="text-secondary gap-2.5 text-xs" 151 - > 152 - <Icon size={13} className="text-text-muted" /> 153 - {label} 154 - </button> 155 - </li> 156 - ))} 157 - </ul> 158 - </details> 159 - 160 - {/* Close panel */} 161 - {depth > 1 && ( 162 - <button onClick={onClosePanel} className="btn btn-ghost btn-sm btn-square rounded-md"> 163 - <XIcon size={14} className="text-text-muted" /> 164 - </button> 165 - )} 166 - </div> 167 - </div> 168 - 169 - {/* Panel body */} 170 - <div className="min-h-0 flex-1 overflow-y-auto"> 171 - {/* Recent bar — root list only */} 172 - {currentPanel.type === "root" && viewMode === "list" && ( 173 - <div className="px-4 pt-4"> 174 - <div className="mb-3 flex items-center gap-1.75"> 175 - <ClockIcon size={12} className="text-text-faint" /> 176 - <span className="text-label text-text-faint tracking-widest uppercase"> 177 - Recent 178 - </span> 179 - </div> 180 - <div className="flex gap-2 overflow-x-auto pb-3 [scrollbar-width:none]"> 181 - {ROOT_ITEMS.slice(5, 9).map((item) => { 182 - const { bg, text } = fileIconColors(item) 183 - return ( 184 - <div 185 - key={`r-${item.id}`} 186 - className="card card-bordered border-base-300/50 bg-base-100 w-32.5 shrink-0 cursor-pointer p-3" 187 - > 188 - <div 189 - className={`mb-2 flex size-6.5 items-center justify-center rounded-md ${bg} ${text}`} 190 - > 191 - {fileIconElement(item, 13)} 192 - </div> 193 - <div className="text-caption text-base-content truncate">{item.name}</div> 194 - <div className="text-label text-text-faint mt-0.5">{item.modified}</div> 195 - </div> 196 - ) 197 - })} 198 - </div> 199 - {/* Ornamental divider */} 200 - <div className="divider text-micro text-text-faint mb-1 tracking-[0.12em] uppercase"> 201 - All files 202 - </div> 203 - </div> 204 - )} 205 - 206 - {/* Section notices */} 207 - {currentPanel.type === "shared" && ( 208 - <div 209 - role="alert" 210 - className="alert border-success/30 bg-bg-sage mx-4 mt-4 gap-2.5 rounded-xl p-3" 211 - > 212 - <UsersIcon size={13} className="text-success mt-0.5 shrink-0" /> 213 - <div> 214 - <div className="text-success mb-0.5 text-xs font-medium"> 215 - Shared via decentralised identity 216 - </div> 217 - <div className="text-caption text-success/80 leading-relaxed"> 218 - Files shared via DID. Encrypted in transit and at rest — only invited parties can 219 - decrypt. 220 - </div> 221 - </div> 222 - </div> 223 - )} 224 - {currentPanel.type === "encrypted" && ( 225 - <div 226 - role="alert" 227 - className="alert border-border-accent bg-accent mx-4 mt-4 gap-2.5 rounded-xl p-3" 228 - > 229 - <LockIcon size={13} className="text-primary mt-0.5 shrink-0" /> 230 - <div> 231 - <div className="text-accent-content mb-0.5 text-xs font-medium"> 232 - Private encrypted files 233 - </div> 234 - <div className="text-caption text-primary leading-relaxed"> 235 - Only you can decrypt these files. Not shared with anyone. 236 - </div> 237 - </div> 238 - </div> 239 - )} 240 - 241 - {loading ? ( 242 - <PanelSkeleton /> 243 - ) : ( 244 - <PanelContent 245 - panel={currentPanel} 246 - viewMode={viewMode} 247 - starredIds={starredIds} 248 - onOpen={onOpenItem} 249 - onStar={onStar} 250 - /> 251 - )} 252 - </div> 253 - 254 - {/* Panel footer */} 255 - <div className="border-base-300/50 bg-base-100/60 flex shrink-0 items-center gap-2 border-t px-4 py-2.25"> 256 - <ShieldCheckIcon size={11} className="text-primary" /> 257 - <span className="text-caption text-text-faint">{footerText}</span> 258 - <div className="flex-1" /> 259 - {depth > 1 && ( 260 - <span className="font-display text-ui text-text-faint italic">{depth} panels open</span> 261 - )} 262 - </div> 263 - </div> 264 - </div> 265 - ) 266 - }
+19 -41
web/src/components/cabinet/Sidebar.tsx
··· 2 2 FolderIcon, 3 3 LockIcon, 4 4 UsersIcon, 5 - StarIcon, 6 5 BookOpenIcon, 7 6 TrashIcon, 8 7 GearIcon, 9 - } from "@phosphor-icons/react" 10 - import { Link } from "@tanstack/react-router" 11 - import { OpakeLogo } from "../OpakeLogo" 12 - import { SidebarItem } from "./SidebarItem" 13 - import type { PanelType, SectionType } from "./types" 8 + } from "@phosphor-icons/react"; 9 + import { Link } from "@tanstack/react-router"; 10 + import { OpakeLogo } from "../OpakeLogo"; 11 + import { SidebarItem } from "./SidebarItem"; 14 12 15 13 const MAIN_NAV = [ 16 - { type: "root" as const, icon: FolderIcon, label: "The Cabinet" }, 17 - { type: "encrypted" as const, icon: LockIcon, label: "Encrypted" }, 18 - { type: "shared" as const, icon: UsersIcon, label: "Shared with me", badge: "4" }, 19 - { type: "starred" as const, icon: StarIcon, label: "Starred" }, 20 - ] 14 + { to: "/cabinet/files" as const, icon: FolderIcon, label: "The Cabinet" }, 15 + { to: "/cabinet/encrypted" as const, icon: LockIcon, label: "Encrypted" }, 16 + { to: "/cabinet/shared" as const, icon: UsersIcon, label: "Shared with me", badge: "4" }, 17 + ]; 21 18 22 19 const BOTTOM_NAV = [ 23 - { type: "docs" as const, icon: BookOpenIcon, label: "Docs & Help" }, 24 - { type: "trash" as const, icon: TrashIcon, label: "TrashIcon" }, 25 - { type: "settings" as const, icon: GearIcon, label: "Settings" }, 26 - ] 20 + { to: "/cabinet/docs" as const, icon: BookOpenIcon, label: "Docs & Help" }, 21 + { to: "/cabinet/trash" as const, icon: TrashIcon, label: "Trash" }, 22 + { to: "/cabinet/settings" as const, icon: GearIcon, label: "Settings" }, 23 + ]; 27 24 28 25 const WORKSPACES = [ 29 26 { id: "ws-personal", name: "Personal", count: 3 }, 30 27 { id: "ws-team", name: "Team Alpha", count: 2 }, 31 - ] 32 - 33 - interface SidebarProps { 34 - activePanelType: PanelType 35 - panelDepth: number 36 - onOpenSection: (type: SectionType, title: string) => void 37 - } 28 + ]; 38 29 39 - export function Sidebar({ activePanelType, panelDepth, onOpenSection }: Readonly<SidebarProps>) { 30 + export function Sidebar() { 40 31 return ( 41 32 <aside className="border-base-300/50 bg-base-200 flex w-53 shrink-0 flex-col border-r px-3 py-4"> 42 33 {/* Logo */} ··· 59 50 60 51 {/* Main nav */} 61 52 <nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto"> 62 - {MAIN_NAV.map(({ type, icon, label, badge }) => ( 63 - <SidebarItem 64 - key={type} 65 - icon={icon} 66 - label={label} 67 - badge={badge} 68 - active={activePanelType === type && panelDepth === 1} 69 - onClick={() => onOpenSection(type, label)} 70 - /> 53 + {MAIN_NAV.map(({ to, icon, label, badge }) => ( 54 + <SidebarItem key={to} to={to} icon={icon} label={label} badge={badge} /> 71 55 ))} 72 56 73 57 {/* Workspaces */} ··· 92 76 <div> 93 77 <div className="divider mx-1 my-0" /> 94 78 <div className="flex flex-col gap-0.5"> 95 - {BOTTOM_NAV.map(({ type, icon, label }) => ( 96 - <SidebarItem 97 - key={type} 98 - icon={icon} 99 - label={label} 100 - active={activePanelType === type} 101 - onClick={() => onOpenSection(type, label)} 102 - /> 79 + {BOTTOM_NAV.map(({ to, icon, label }) => ( 80 + <SidebarItem key={to} to={to} icon={icon} label={label} /> 103 81 ))} 104 82 </div> 105 83 </div> 106 84 </aside> 107 - ) 85 + ); 108 86 }
+14 -17
web/src/components/cabinet/SidebarItem.tsx
··· 1 - import type { Icon as PhosphorIcon } from "@phosphor-icons/react" 1 + import type { Icon as PhosphorIcon } from "@phosphor-icons/react"; 2 + import { Link, useMatchRoute } from "@tanstack/react-router"; 2 3 3 4 interface SidebarItemProps { 4 - icon: PhosphorIcon 5 - label: string 6 - active: boolean 7 - badge?: string | number 8 - onClick: () => void 5 + readonly to: string; 6 + readonly icon: PhosphorIcon; 7 + readonly label: string; 8 + readonly badge?: string | number; 9 9 } 10 10 11 - export function SidebarItem({ 12 - icon: Icon, 13 - label, 14 - active, 15 - badge, 16 - onClick, 17 - }: Readonly<SidebarItemProps>) { 11 + export function SidebarItem({ to, icon: Icon, label, badge }: SidebarItemProps) { 12 + const matchRoute = useMatchRoute(); 13 + const active = Boolean(matchRoute({ to, fuzzy: true })); 14 + 18 15 return ( 19 - <button 20 - onClick={onClick} 16 + <Link 17 + to={to} 21 18 className={`text-ui flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.75 text-left transition-colors ${ 22 19 active ? "bg-accent text-primary" : "text-text-muted hover:bg-bg-hover" 23 20 }`} ··· 33 30 {badge} 34 31 </span> 35 32 )} 36 - </button> 37 - ) 33 + </Link> 34 + ); 38 35 }
+6 -6
web/src/components/cabinet/StatusBadge.tsx
··· 1 - import { LockIcon, UsersIcon, GlobeIcon } from "@phosphor-icons/react" 2 - import type { EncStatus } from "./types" 1 + import { LockIcon, UsersIcon, GlobeIcon } from "@phosphor-icons/react"; 2 + import type { EncStatus } from "./types"; 3 3 4 4 const VARIANTS: Readonly< 5 5 Record<EncStatus, { className: string; icon: typeof LockIcon; label: string }> ··· 19 19 icon: GlobeIcon, 20 20 label: "Public", 21 21 }, 22 - } 22 + }; 23 23 24 24 export function StatusBadge({ status }: Readonly<{ status: EncStatus }>) { 25 - const variant = VARIANTS[status] 26 - const Icon = variant.icon 25 + const variant = VARIANTS[status]; 26 + const Icon = variant.icon; 27 27 28 28 return ( 29 29 <span className={`badge badge-sm text-label gap-1 border tracking-wide ${variant.className}`}> 30 30 <Icon size={8} weight="bold" /> 31 31 {variant.label} 32 32 </span> 33 - ) 33 + ); 34 34 }
+47
web/src/components/cabinet/TagFilterBar.tsx
··· 1 + import { XIcon } from "@phosphor-icons/react"; 2 + 3 + interface TagFilterBarProps { 4 + readonly availableTags: readonly string[]; 5 + readonly activeFilters: readonly string[]; 6 + readonly onToggle: (tag: string) => void; 7 + readonly onClear: () => void; 8 + } 9 + 10 + export function TagFilterBar({ 11 + availableTags, 12 + activeFilters, 13 + onToggle, 14 + onClear, 15 + }: TagFilterBarProps) { 16 + if (availableTags.length === 0) return null; 17 + 18 + const hasActive = activeFilters.length > 0; 19 + 20 + return ( 21 + <div className="flex items-center gap-1.5 overflow-x-auto px-4 py-2 [scrollbar-width:none]"> 22 + {hasActive && ( 23 + <button 24 + onClick={onClear} 25 + className="btn btn-ghost btn-xs text-text-faint gap-1 rounded-full" 26 + > 27 + <XIcon size={10} /> 28 + Clear 29 + </button> 30 + )} 31 + {availableTags.map((tag) => { 32 + const isActive = activeFilters.includes(tag); 33 + return ( 34 + <button 35 + key={tag} 36 + onClick={() => onToggle(tag)} 37 + className={`btn btn-xs rounded-full ${ 38 + isActive ? "btn-primary" : "btn-ghost border-base-300/50 border" 39 + }`} 40 + > 41 + {tag} 42 + </button> 43 + ); 44 + })} 45 + </div> 46 + ); 47 + }
+16 -23
web/src/components/cabinet/TopBar.tsx
··· 7 7 LockIcon, 8 8 GearIcon, 9 9 SignOutIcon, 10 - } from "@phosphor-icons/react" 11 - import { Link } from "@tanstack/react-router" 10 + } from "@phosphor-icons/react"; 11 + import { Link } from "@tanstack/react-router"; 12 12 13 13 interface TopBarProps { 14 - searchQuery: string 15 - onSearchChange: (query: string) => void 16 - onOpenSettings: () => void 14 + readonly searchQuery: string; 15 + readonly onSearchChange: (query: string) => void; 17 16 } 18 17 19 18 function closeDropdown(e: React.MouseEvent) { 20 - e.currentTarget.closest("details")?.removeAttribute("open") 19 + e.currentTarget.closest("details")?.removeAttribute("open"); 21 20 } 22 21 23 - export function TopBar({ searchQuery, onSearchChange, onOpenSettings }: Readonly<TopBarProps>) { 22 + export function TopBar({ searchQuery, onSearchChange }: TopBarProps) { 24 23 return ( 25 24 <header className="border-base-300/50 bg-base-300/90 flex shrink-0 items-center gap-3 border-b px-5 py-2.5 backdrop-blur-[10px]"> 26 25 {/* Search */} ··· 28 27 <MagnifyingGlassIcon size={13} className="text-text-faint" /> 29 28 <input 30 29 type="text" 31 - placeholder="Search your cabinet…" 30 + placeholder="Search your cabinet\u2026" 32 31 value={searchQuery} 33 32 onChange={(e) => onSearchChange(e.target.value)} 34 33 className="text-secondary grow bg-transparent" ··· 59 58 </button> 60 59 </div> 61 60 62 - {/* UserIcon menu */} 61 + {/* User menu */} 63 62 <details className="dropdown dropdown-end"> 64 63 <summary className="btn btn-ghost btn-sm gap-2 rounded-lg pl-1"> 65 64 <div className="bg-accent text-caption text-primary flex size-7 items-center justify-center rounded-full font-semibold"> ··· 70 69 <div className="dropdown-content border-base-300/50 bg-base-100 shadow-panel-lg z-50 w-52.5 rounded-xl border"> 71 70 <div className="border-base-300/50 border-b px-3.5 py-2.5"> 72 71 <div className="text-ui text-base-content font-medium">alice.bsky.social</div> 73 - <div className="text-caption text-text-faint mt-0.5">did:plc:7f2ab3c4…8e91</div> 72 + <div className="text-caption text-text-faint mt-0.5">did:plc:7f2ab3c4\u20268e91</div> 74 73 </div> 75 74 <ul className="menu p-1"> 76 75 {[ 77 - { icon: UserIcon, label: "Profile & DID" }, 78 - { icon: LockIcon, label: "Encryption Keys" }, 79 - { icon: GearIcon, label: "Settings" }, 80 - ].map(({ icon: Icon, label }) => ( 76 + { icon: UserIcon, label: "Profile & DID", to: "/cabinet/settings" as const }, 77 + { icon: LockIcon, label: "Encryption Keys", to: "/cabinet/settings" as const }, 78 + { icon: GearIcon, label: "Settings", to: "/cabinet/settings" as const }, 79 + ].map(({ icon: Icon, label, to }) => ( 81 80 <li key={label}> 82 - <button 83 - onClick={(e) => { 84 - onOpenSettings() 85 - closeDropdown(e) 86 - }} 87 - className="text-secondary gap-2.5 text-xs" 88 - > 81 + <Link to={to} onClick={closeDropdown} className="text-secondary gap-2.5 text-xs"> 89 82 <Icon size={13} /> 90 83 {label} 91 - </button> 84 + </Link> 92 85 </li> 93 86 ))} 94 87 </ul> ··· 104 97 </div> 105 98 </details> 106 99 </header> 107 - ) 100 + ); 108 101 }
+15 -15
web/src/components/cabinet/file-icons.tsx web/src/components/cabinet/FileIcons.tsx
··· 4 4 FileIcon, 5 5 BookOpenIcon, 6 6 ArchiveIcon, 7 - } from "@phosphor-icons/react" 8 - import type { FileItem } from "./types" 7 + } from "@phosphor-icons/react"; 8 + import type { FileItem } from "./types"; 9 9 10 10 interface IconStyle { 11 - bg: string 12 - text: string 11 + bg: string; 12 + text: string; 13 13 } 14 14 15 - const FOLDER_STYLE: Readonly<IconStyle> = { bg: "bg-accent", text: "text-primary" } 15 + const FOLDER_STYLE: Readonly<IconStyle> = { bg: "bg-accent", text: "text-primary" }; 16 16 17 17 const FILE_TYPE_STYLES: Readonly<Record<string, IconStyle>> = { 18 18 document: { bg: "bg-file-doc-bg", text: "text-file-doc" }, ··· 21 21 note: { bg: "bg-accent", text: "text-file-note" }, 22 22 code: { bg: "bg-file-code-bg", text: "text-file-code" }, 23 23 archive: { bg: "bg-bg-stone", text: "text-text-muted" }, 24 - } 24 + }; 25 25 26 - const DEFAULT_STYLE: Readonly<IconStyle> = { bg: "bg-bg-stone", text: "text-text-muted" } 26 + const DEFAULT_STYLE: Readonly<IconStyle> = { bg: "bg-bg-stone", text: "text-text-muted" }; 27 27 28 28 export function fileIconColors(item: FileItem): IconStyle { 29 - if (item.kind === "folder") return FOLDER_STYLE 30 - return FILE_TYPE_STYLES[item.fileType ?? ""] ?? DEFAULT_STYLE 29 + if (item.kind === "folder") return FOLDER_STYLE; 30 + return FILE_TYPE_STYLES[item.fileType ?? ""] ?? DEFAULT_STYLE; 31 31 } 32 32 33 33 export function fileIconElement(item: FileItem, size = 15) { 34 - if (item.kind === "folder") return <FolderIcon size={size} weight="fill" /> 34 + if (item.kind === "folder") return <FolderIcon size={size} weight="fill" />; 35 35 switch (item.fileType) { 36 36 case "document": 37 - return <FileTextIcon size={size} /> 37 + return <FileTextIcon size={size} />; 38 38 case "spreadsheet": 39 - return <FileIcon size={size} /> 39 + return <FileIcon size={size} />; 40 40 case "note": 41 - return <BookOpenIcon size={size} /> 41 + return <BookOpenIcon size={size} />; 42 42 case "archive": 43 - return <ArchiveIcon size={size} /> 43 + return <ArchiveIcon size={size} />; 44 44 default: 45 - return <FileIcon size={size} /> 45 + return <FileIcon size={size} />; 46 46 } 47 47 }
-218
web/src/components/cabinet/mock-data.ts
··· 1 - import type { FileItem } from "./types" 2 - 3 - export const ROOT_ITEMS: readonly FileItem[] = [ 4 - { 5 - id: "f-documents", 6 - name: "Documents", 7 - kind: "folder", 8 - encrypted: true, 9 - status: "private", 10 - items: 23, 11 - modified: "2 hours ago", 12 - starred: false, 13 - }, 14 - { 15 - id: "f-projects", 16 - name: "Projects", 17 - kind: "folder", 18 - encrypted: true, 19 - status: "shared", 20 - sharedWith: ["alice.did", "bob.did"], 21 - items: 7, 22 - modified: "Yesterday", 23 - starred: true, 24 - }, 25 - { 26 - id: "f-photos", 27 - name: "Photos", 28 - kind: "folder", 29 - encrypted: true, 30 - status: "private", 31 - items: 156, 32 - modified: "3 days ago", 33 - starred: false, 34 - }, 35 - { 36 - id: "f-notes", 37 - name: "Notes", 38 - kind: "folder", 39 - encrypted: true, 40 - status: "private", 41 - items: 44, 42 - modified: "Just now", 43 - starred: false, 44 - }, 45 - { 46 - id: "f-archive", 47 - name: "ArchiveIcon", 48 - kind: "folder", 49 - encrypted: true, 50 - status: "private", 51 - items: 12, 52 - modified: "1 week ago", 53 - starred: false, 54 - }, 55 - { 56 - id: "fi-strategy", 57 - name: "Q4 Strategy.doc", 58 - kind: "file", 59 - fileType: "document", 60 - encrypted: true, 61 - status: "private", 62 - size: "245 KB", 63 - modified: "2 hours ago", 64 - starred: true, 65 - }, 66 - { 67 - id: "fi-budget", 68 - name: "Budget 2026.xlsx", 69 - kind: "file", 70 - fileType: "spreadsheet", 71 - encrypted: true, 72 - status: "shared", 73 - sharedWith: ["carol.did"], 74 - size: "1.2 MB", 75 - modified: "3 days ago", 76 - starred: false, 77 - }, 78 - { 79 - id: "fi-brief", 80 - name: "Design Brief.pdf", 81 - kind: "file", 82 - fileType: "pdf", 83 - encrypted: true, 84 - status: "private", 85 - size: "3.4 MB", 86 - modified: "Yesterday", 87 - starred: true, 88 - }, 89 - { 90 - id: "fi-notes", 91 - name: "Team Notes.md", 92 - kind: "file", 93 - fileType: "note", 94 - encrypted: true, 95 - status: "shared", 96 - sharedWith: ["alice.did", "bob.did", "carol.did"], 97 - size: "18 KB", 98 - modified: "Just now", 99 - starred: false, 100 - }, 101 - { 102 - id: "fi-api", 103 - name: "API Contracts.json", 104 - kind: "file", 105 - fileType: "code", 106 - encrypted: true, 107 - status: "private", 108 - size: "67 KB", 109 - modified: "1 week ago", 110 - starred: false, 111 - }, 112 - ] 113 - 114 - export const DOCUMENTS_ITEMS: readonly FileItem[] = [ 115 - { 116 - id: "d-reports", 117 - name: "Reports", 118 - kind: "folder", 119 - encrypted: true, 120 - status: "private", 121 - items: 8, 122 - modified: "1 week ago", 123 - starred: false, 124 - }, 125 - { 126 - id: "d-contracts", 127 - name: "Contracts", 128 - kind: "folder", 129 - encrypted: true, 130 - status: "shared", 131 - sharedWith: ["legal.did"], 132 - items: 5, 133 - modified: "2 weeks ago", 134 - starred: false, 135 - }, 136 - { 137 - id: "d-thesis", 138 - name: "Thesis Draft v4.doc", 139 - kind: "file", 140 - fileType: "document", 141 - encrypted: true, 142 - status: "private", 143 - size: "1.8 MB", 144 - modified: "3 days ago", 145 - starred: true, 146 - }, 147 - { 148 - id: "d-cv", 149 - name: "CV 2026.pdf", 150 - kind: "file", 151 - fileType: "pdf", 152 - encrypted: true, 153 - status: "private", 154 - size: "340 KB", 155 - modified: "1 month ago", 156 - starred: false, 157 - }, 158 - { 159 - id: "d-ref", 160 - name: "Reference Notes.md", 161 - kind: "file", 162 - fileType: "note", 163 - encrypted: true, 164 - status: "private", 165 - size: "88 KB", 166 - modified: "5 days ago", 167 - starred: false, 168 - }, 169 - ] 170 - 171 - export const SHARED_ITEMS: readonly FileItem[] = [ 172 - { 173 - id: "sh-1", 174 - name: "Product Roadmap.doc", 175 - kind: "file", 176 - fileType: "document", 177 - encrypted: true, 178 - status: "shared", 179 - sharedWith: ["team.did"], 180 - size: "512 KB", 181 - modified: "1 hour ago", 182 - starred: false, 183 - }, 184 - { 185 - id: "sh-2", 186 - name: "Sprint Board", 187 - kind: "folder", 188 - encrypted: true, 189 - status: "shared", 190 - sharedWith: ["alice.did", "bob.did", "carol.did"], 191 - items: 9, 192 - modified: "30 min ago", 193 - starred: true, 194 - }, 195 - { 196 - id: "sh-3", 197 - name: "Brand Assets", 198 - kind: "folder", 199 - encrypted: true, 200 - status: "shared", 201 - sharedWith: ["design.did"], 202 - items: 34, 203 - modified: "2 days ago", 204 - starred: false, 205 - }, 206 - { 207 - id: "sh-4", 208 - name: "Meeting Notes Q1.md", 209 - kind: "file", 210 - fileType: "note", 211 - encrypted: true, 212 - status: "shared", 213 - sharedWith: ["alice.did"], 214 - size: "22 KB", 215 - modified: "1 week ago", 216 - starred: false, 217 - }, 218 - ]
+16 -32
web/src/components/cabinet/types.ts
··· 1 - export type EncStatus = "private" | "shared" | "public" 1 + export type EncStatus = "private" | "shared" | "public"; 2 2 3 - export type FileType = "document" | "spreadsheet" | "pdf" | "image" | "code" | "note" | "archive" 3 + export type FileType = "document" | "spreadsheet" | "pdf" | "image" | "code" | "note" | "archive"; 4 4 5 5 export interface FileItem { 6 - id: string 7 - name: string 8 - kind: "file" | "folder" 9 - fileType?: FileType 10 - encrypted: boolean 11 - status: EncStatus 12 - sharedWith?: string[] 13 - size?: string 14 - items?: number 15 - modified: string 16 - starred: boolean 17 - } 18 - 19 - export type SectionType = 20 - | "root" 21 - | "shared" 22 - | "starred" 23 - | "encrypted" 24 - | "docs" 25 - | "trash" 26 - | "settings" 27 - 28 - export type PanelType = SectionType | "folder" 29 - 30 - export type Panel = 31 - | { type: "folder"; folderId: string; title: string; itemCount?: number } 32 - | { type: SectionType; title: string } 33 - 34 - export function panelKey(panel: Panel): string { 35 - return panel.type === "folder" ? panel.folderId : panel.type 6 + id: string; 7 + uri: string; 8 + name: string; 9 + kind: "file" | "folder"; 10 + fileType?: FileType; 11 + encrypted: boolean; 12 + status: EncStatus; 13 + size?: string; 14 + items?: number; 15 + modified: string; 16 + decrypted: boolean; 17 + tags: string[]; 18 + mimeType?: string; 19 + description?: string; 36 20 }
+6 -1
web/src/components/devices/PageHeader.tsx
··· 7 7 readonly iconClassName?: string; 8 8 } 9 9 10 - export function PageHeader({ title, description, icon: IconComponent, iconClassName }: PageHeaderProps) { 10 + export function PageHeader({ 11 + title, 12 + description, 13 + icon: IconComponent, 14 + iconClassName, 15 + }: PageHeaderProps) { 11 16 return ( 12 17 <> 13 18 {IconComponent && (
+80 -80
web/src/lib/api.ts
··· 1 1 // XRPC and AppView API helpers. 2 2 3 - import type { OAuthSession, Session } from "@/lib/storage-types" 4 - import type { TokenResponse } from "@/lib/oauth" 5 - import { getCryptoWorker } from "@/lib/worker" 6 - import { IndexedDbStorage } from "@/lib/indexeddb-storage" 3 + import type { OAuthSession, Session } from "@/lib/storageTypes"; 4 + import type { TokenResponse } from "@/lib/oauth"; 5 + import { getCryptoWorker } from "@/lib/worker"; 6 + import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 7 7 8 8 interface ApiConfig { 9 - pdsUrl: string 10 - appviewUrl: string 9 + pdsUrl: string; 10 + appviewUrl: string; 11 11 } 12 12 13 13 const defaultConfig: Readonly<ApiConfig> = { 14 14 pdsUrl: (import.meta.env.VITE_PDS_URL as string | undefined) ?? "https://pds.sans-self.org", 15 15 appviewUrl: 16 16 (import.meta.env.VITE_APPVIEW_URL as string | undefined) ?? "https://appview.opake.app", 17 - } 17 + }; 18 18 19 19 // --------------------------------------------------------------------------- 20 20 // Unauthenticated XRPC 21 21 // --------------------------------------------------------------------------- 22 22 23 23 interface XrpcParams { 24 - lexicon: string 25 - method?: "GET" | "POST" 26 - body?: unknown 27 - headers?: Record<string, string> 24 + lexicon: string; 25 + method?: "GET" | "POST"; 26 + body?: unknown; 27 + headers?: Record<string, string>; 28 28 } 29 29 30 30 export async function xrpc( 31 31 params: XrpcParams, 32 32 config: ApiConfig = defaultConfig, 33 33 ): Promise<unknown> { 34 - const { lexicon, method = "GET", body, headers = {} } = params 35 - const url = `${config.pdsUrl}/xrpc/${lexicon}` 34 + const { lexicon, method = "GET", body, headers = {} } = params; 35 + const url = `${config.pdsUrl}/xrpc/${lexicon}`; 36 36 37 37 const response = await fetch(url, { 38 38 method, ··· 41 41 ...headers, 42 42 }, 43 43 body: body ? JSON.stringify(body) : undefined, 44 - }) 44 + }); 45 45 46 46 if (!response.ok) { 47 - throw new Error(`XRPC ${lexicon}: ${response.status}`) 47 + throw new Error(`XRPC ${lexicon}: ${response.status}`); 48 48 } 49 49 50 - return response.json() 50 + return response.json(); 51 51 } 52 52 53 53 // --------------------------------------------------------------------------- ··· 55 55 // --------------------------------------------------------------------------- 56 56 57 57 interface AuthenticatedXrpcParams { 58 - pdsUrl: string 59 - lexicon: string 60 - method?: "GET" | "POST" 61 - body?: unknown 58 + pdsUrl: string; 59 + lexicon: string; 60 + method?: "GET" | "POST"; 61 + body?: unknown; 62 62 } 63 63 64 64 // eslint-disable-next-line sonarjs/cognitive-complexity -- legitimate retry/nonce dance with nested conditions; splitting would obscure the flow ··· 66 66 params: AuthenticatedXrpcParams, 67 67 session: Session, 68 68 ): Promise<unknown> { 69 - const { pdsUrl, lexicon, method = "GET", body } = params 70 - const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/${lexicon}` 71 - const jsonBody = body ? JSON.stringify(body) : undefined 69 + const { pdsUrl, lexicon, method = "GET", body } = params; 70 + const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/${lexicon}`; 71 + const jsonBody = body ? JSON.stringify(body) : undefined; 72 72 73 73 const headers: Record<string, string> = { 74 74 "Content-Type": "application/json", 75 - } 75 + }; 76 76 77 77 if (session.type === "oauth") { 78 - await attachDpopAuth(headers, session, method, url) 78 + await attachDpopAuth(headers, session, method, url); 79 79 } else { 80 - headers.Authorization = `Bearer ${session.accessJwt}` 80 + headers.Authorization = `Bearer ${session.accessJwt}`; 81 81 } 82 82 83 - let response = await fetch(url, { method, headers, body: jsonBody }) 83 + let response = await fetch(url, { method, headers, body: jsonBody }); 84 84 85 85 // DPoP nonce retry — the PDS has a different nonce than the AS. 86 86 if (session.type === "oauth" && requiresNonceRetry(response)) { 87 - const nonce = response.headers.get("dpop-nonce") 87 + const nonce = response.headers.get("dpop-nonce"); 88 88 if (nonce) { 89 - session.dpopNonce = nonce 90 - await attachDpopAuth(headers, session, method, url) 91 - response = await fetch(url, { method, headers, body: jsonBody }) 89 + session.dpopNonce = nonce; 90 + await attachDpopAuth(headers, session, method, url); 91 + response = await fetch(url, { method, headers, body: jsonBody }); 92 92 } 93 93 } 94 94 95 95 // Token expired — refresh and retry once. 96 96 if (response.status === 401 && session.type === "oauth" && session.refreshToken) { 97 - console.debug("[api] 401 — attempting token refresh") 98 - const refreshed = await refreshAccessToken(session) 97 + console.debug("[api] 401 — attempting token refresh"); 98 + const refreshed = await refreshAccessToken(session); 99 99 if (refreshed) { 100 - await attachDpopAuth(headers, session, method, url) 101 - response = await fetch(url, { method, headers, body: jsonBody }) 100 + await attachDpopAuth(headers, session, method, url); 101 + response = await fetch(url, { method, headers, body: jsonBody }); 102 102 103 103 // The refreshed token might also need a nonce retry on the PDS 104 104 if (requiresNonceRetry(response)) { 105 - const nonce = response.headers.get("dpop-nonce") 105 + const nonce = response.headers.get("dpop-nonce"); 106 106 if (nonce) { 107 - session.dpopNonce = nonce 108 - await attachDpopAuth(headers, session, method, url) 109 - response = await fetch(url, { method, headers, body: jsonBody }) 107 + session.dpopNonce = nonce; 108 + await attachDpopAuth(headers, session, method, url); 109 + response = await fetch(url, { method, headers, body: jsonBody }); 110 110 } 111 111 } 112 112 } 113 113 } 114 114 115 115 if (!response.ok) { 116 - const detail = await response.text().catch(() => "") 117 - throw new Error(`XRPC ${lexicon}: ${response.status} ${detail}`.trim()) 116 + const detail = await response.text().catch(() => ""); 117 + throw new Error(`XRPC ${lexicon}: ${response.status} ${detail}`.trim()); 118 118 } 119 119 120 - return response.json() 120 + return response.json(); 121 121 } 122 122 123 123 // --------------------------------------------------------------------------- 124 124 // Token refresh 125 125 // --------------------------------------------------------------------------- 126 126 127 - const storage = new IndexedDbStorage() 127 + const storage = new IndexedDbStorage(); 128 128 129 129 /** Refresh an expired OAuth access token. Mutates the session in place and persists to IndexedDB. */ 130 130 async function refreshAccessToken(session: OAuthSession): Promise<boolean> { 131 - const worker = getCryptoWorker() 132 - const url = session.tokenEndpoint 131 + const worker = getCryptoWorker(); 132 + const url = session.tokenEndpoint; 133 133 134 134 const body = new URLSearchParams({ 135 135 grant_type: "refresh_token", 136 136 refresh_token: session.refreshToken, 137 137 client_id: session.clientId, 138 - }) 138 + }); 139 139 140 - const timestamp = Math.floor(Date.now() / 1000) 140 + const timestamp = Math.floor(Date.now() / 1000); 141 141 const proof = await worker.createDpopProof( 142 142 session.dpopKey, 143 143 "POST", ··· 145 145 timestamp, 146 146 session.dpopNonce, 147 147 null, 148 - ) 148 + ); 149 149 150 150 const headers: Record<string, string> = { 151 151 "Content-Type": "application/x-www-form-urlencoded", 152 152 DPoP: proof, 153 - } 153 + }; 154 154 155 - let response = await fetch(url, { method: "POST", headers, body: body.toString() }) 156 - let nonce = response.headers.get("dpop-nonce") ?? session.dpopNonce 155 + let response = await fetch(url, { method: "POST", headers, body: body.toString() }); 156 + let nonce = response.headers.get("dpop-nonce") ?? session.dpopNonce; 157 157 158 158 // Nonce retry for the AS 159 159 if (response.status === 400) { ··· 161 161 .clone() 162 162 .json() 163 163 .catch(() => null)) as { 164 - error?: string 165 - } | null 164 + error?: string; 165 + } | null; 166 166 if (errorBody?.error === "use_dpop_nonce" && nonce) { 167 167 const retryProof = await worker.createDpopProof( 168 168 session.dpopKey, ··· 171 171 timestamp, 172 172 nonce, 173 173 null, 174 - ) 175 - headers.DPoP = retryProof 176 - response = await fetch(url, { method: "POST", headers, body: body.toString() }) 177 - nonce = response.headers.get("dpop-nonce") ?? nonce 174 + ); 175 + headers.DPoP = retryProof; 176 + response = await fetch(url, { method: "POST", headers, body: body.toString() }); 177 + nonce = response.headers.get("dpop-nonce") ?? nonce; 178 178 } 179 179 } 180 180 181 181 if (!response.ok) { 182 - console.error("[api] token refresh failed:", response.status) 183 - return false 182 + console.error("[api] token refresh failed:", response.status); 183 + return false; 184 184 } 185 185 186 - const tokenResponse = (await response.json()) as TokenResponse 187 - console.debug("[api] token refreshed, new expiry:", tokenResponse.expires_in) 186 + const tokenResponse = (await response.json()) as TokenResponse; 187 + console.debug("[api] token refreshed, new expiry:", tokenResponse.expires_in); 188 188 189 - const now = Math.floor(Date.now() / 1000) 190 - session.accessToken = tokenResponse.access_token 191 - session.refreshToken = tokenResponse.refresh_token ?? session.refreshToken 192 - session.dpopNonce = nonce 193 - session.expiresAt = tokenResponse.expires_in ? now + tokenResponse.expires_in : null 189 + const now = Math.floor(Date.now() / 1000); 190 + session.accessToken = tokenResponse.access_token; 191 + session.refreshToken = tokenResponse.refresh_token ?? session.refreshToken; 192 + session.dpopNonce = nonce; 193 + session.expiresAt = tokenResponse.expires_in ? now + tokenResponse.expires_in : null; 194 194 195 195 // Persist updated session 196 196 await storage.saveSession(session.did, session).catch((err: unknown) => { 197 - console.warn("[api] failed to persist refreshed session:", err) 198 - }) 197 + console.warn("[api] failed to persist refreshed session:", err); 198 + }); 199 199 200 - return true 200 + return true; 201 201 } 202 202 203 203 /** Check if a response is a DPoP nonce challenge (400 use_dpop_nonce or 401 with nonce header). */ 204 204 function requiresNonceRetry(response: Response): boolean { 205 205 if (response.headers.has("dpop-nonce")) { 206 - if (response.status === 401) return true 207 - if (response.status === 400) return true 206 + if (response.status === 401) return true; 207 + if (response.status === 400) return true; 208 208 } 209 - return false 209 + return false; 210 210 } 211 211 212 212 async function attachDpopAuth( ··· 215 215 method: string, 216 216 url: string, 217 217 ): Promise<void> { 218 - const worker = getCryptoWorker() 219 - const timestamp = Math.floor(Date.now() / 1000) 218 + const worker = getCryptoWorker(); 219 + const timestamp = Math.floor(Date.now() / 1000); 220 220 const proof = await worker.createDpopProof( 221 221 session.dpopKey, 222 222 method, ··· 224 224 timestamp, 225 225 session.dpopNonce, 226 226 session.accessToken, 227 - ) 228 - headers.Authorization = `DPoP ${session.accessToken}` 229 - headers.DPoP = proof 227 + ); 228 + headers.Authorization = `DPoP ${session.accessToken}`; 229 + headers.DPoP = proof; 230 230 } 231 231 232 232 // --------------------------------------------------------------------------- ··· 234 234 // --------------------------------------------------------------------------- 235 235 236 236 export async function appview(path: string, config: ApiConfig = defaultConfig): Promise<unknown> { 237 - const response = await fetch(`${config.appviewUrl}${path}`) 237 + const response = await fetch(`${config.appviewUrl}${path}`); 238 238 239 239 if (!response.ok) { 240 - throw new Error(`AppView ${path}: ${response.status}`) 240 + throw new Error(`AppView ${path}: ${response.status}`); 241 241 } 242 242 243 - return response.json() 243 + return response.json(); 244 244 }
+10
web/src/lib/atUri.ts
··· 1 + /** Extract the rkey (last path segment) from an AT Protocol URI. */ 2 + export function rkeyFromUri(uri: string): string { 3 + const segments = uri.split("/"); 4 + return segments[segments.length - 1]; 5 + } 6 + 7 + /** Build a full AT URI for a directory record. */ 8 + export function directoryUri(did: string, rkey: string): string { 9 + return `at://${did}/app.opake.directory/${rkey}`; 10 + }
+16 -16
web/src/lib/crypto-types.ts web/src/lib/cryptoTypes.ts
··· 1 1 export interface AtBytes { 2 - $bytes: string 2 + $bytes: string; 3 3 } 4 4 5 5 export interface WrappedKey { 6 - did: string 7 - ciphertext: AtBytes 8 - algo: string 6 + did: string; 7 + ciphertext: AtBytes; 8 + algo: string; 9 9 } 10 10 11 11 export interface EncryptedPayload { 12 - ciphertext: Uint8Array 13 - nonce: Uint8Array 12 + ciphertext: Uint8Array; 13 + nonce: Uint8Array; 14 14 } 15 15 16 16 // Mirrors: opake-core DpopPublicJwk (client/dpop.rs) 17 17 export interface DpopPublicJwk { 18 - kty: string 19 - crv: string 20 - x: string 21 - y: string 18 + kty: string; 19 + crv: string; 20 + x: string; 21 + y: string; 22 22 } 23 23 24 24 // Mirrors: opake-core DpopKeyPair (client/dpop.rs) 25 25 // Serialized via serde — field names match Rust's #[serde(rename)] 26 26 export interface DpopKeyPair { 27 - privateKey: string // base64url P-256 secret 28 - publicJwk: DpopPublicJwk 27 + privateKey: string; // base64url P-256 secret 28 + publicJwk: DpopPublicJwk; 29 29 } 30 30 31 31 // Mirrors: opake-core PkceChallenge (client/oauth_discovery.rs) 32 32 export interface PkceChallenge { 33 - verifier: string 34 - challenge: string 33 + verifier: string; 34 + challenge: string; 35 35 } 36 36 37 37 // Mirrors: opake-core EphemeralKeypair (crypto/mod.rs) 38 38 export interface EphemeralKeypair { 39 - publicKey: Uint8Array 40 - privateKey: Uint8Array 39 + publicKey: Uint8Array; 40 + privateKey: Uint8Array; 41 41 }
+13 -13
web/src/lib/encoding.ts
··· 2 2 3 3 /** Standard base64 from bytes. */ 4 4 export function uint8ArrayToBase64(bytes: Uint8Array): string { 5 - let binary = "" 5 + let binary = ""; 6 6 for (const byte of bytes) { 7 - binary += String.fromCharCode(byte) 7 + binary += String.fromCharCode(byte); 8 8 } 9 - return btoa(binary) 9 + return btoa(binary); 10 10 } 11 11 12 12 /** Base64 to bytes — handles unpadded strings (PDS returns unpadded). */ 13 13 export function base64ToUint8Array(b64: string): Uint8Array { 14 14 // Pad to multiple of 4 15 - const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4) 16 - const binary = atob(padded) 17 - const bytes = new Uint8Array(binary.length) 15 + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); 16 + const binary = atob(padded); 17 + const bytes = new Uint8Array(binary.length); 18 18 for (let i = 0; i < binary.length; i++) { 19 - bytes[i] = binary.charCodeAt(i) 19 + bytes[i] = binary.charCodeAt(i); 20 20 } 21 - return bytes 21 + return bytes; 22 22 } 23 23 24 24 /** First 8 bytes of a public key as a colon-separated hex fingerprint. */ 25 25 export function formatFingerprint(pubkey: Uint8Array): string { 26 26 return Array.from(pubkey.slice(0, 8)) 27 27 .map((b) => b.toString(16).padStart(2, "0")) 28 - .join(":") 28 + .join(":"); 29 29 } 30 30 31 31 /** Extract the rkey from an AT-URI: `at://did/collection/rkey` → `rkey`. */ 32 32 export function rkeyFromUri(atUri: string): string { 33 - const parts = atUri.split("/") 34 - const rkey = parts.at(-1) 35 - if (!rkey) throw new Error(`Invalid AT-URI: ${atUri}`) 36 - return rkey 33 + const parts = atUri.split("/"); 34 + const rkey = parts.at(-1); 35 + if (!rkey) throw new Error(`Invalid AT-URI: ${atUri}`); 36 + return rkey; 37 37 }
+95
web/src/lib/format.ts
··· 1 + // Display formatting utilities for the cabinet UI. 2 + 3 + import type { FileType } from "@/components/cabinet/types"; 4 + 5 + const MIME_TO_FILE_TYPE: ReadonlyMap<string, FileType> = new Map([ 6 + ["application/pdf", "pdf"], 7 + ["text/markdown", "note"], 8 + ["text/plain", "document"], 9 + ["text/csv", "spreadsheet"], 10 + ["application/json", "code"], 11 + ["application/javascript", "code"], 12 + ["text/javascript", "code"], 13 + ["text/typescript", "code"], 14 + ["text/html", "code"], 15 + ["text/css", "code"], 16 + ["text/xml", "code"], 17 + ["application/xml", "code"], 18 + ["application/zip", "archive"], 19 + ["application/gzip", "archive"], 20 + ["application/x-tar", "archive"], 21 + ["application/x-7z-compressed", "archive"], 22 + ["application/x-rar-compressed", "archive"], 23 + ]); 24 + 25 + const MIME_PREFIX_TO_FILE_TYPE: ReadonlyMap<string, FileType> = new Map([ 26 + ["image/", "image"], 27 + ["application/vnd.openxmlformats-officedocument.spreadsheetml", "spreadsheet"], 28 + ["application/vnd.ms-excel", "spreadsheet"], 29 + ["application/vnd.openxmlformats-officedocument.wordprocessingml", "document"], 30 + ["application/msword", "document"], 31 + ["application/vnd.openxmlformats-officedocument.presentationml", "document"], 32 + ]); 33 + 34 + /** Map a MIME type to a cabinet FileType category. */ 35 + export function mimeTypeToFileType(mime: string): FileType { 36 + const exact = MIME_TO_FILE_TYPE.get(mime); 37 + if (exact) return exact; 38 + 39 + const prefixMatch = [...MIME_PREFIX_TO_FILE_TYPE.entries()].find(([prefix]) => 40 + mime.startsWith(prefix), 41 + ); 42 + 43 + return prefixMatch ? prefixMatch[1] : "document"; 44 + } 45 + 46 + const SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; 47 + 48 + /** Format byte count as human-readable size (e.g. 1048576 → "1 MB"). */ 49 + export function formatFileSize(bytes: number): string { 50 + if (bytes === 0) return "0 B"; 51 + 52 + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), SIZE_UNITS.length - 1); 53 + const value = bytes / Math.pow(1024, exponent); 54 + const formatted = exponent === 0 ? value.toString() : value.toFixed(value < 10 ? 1 : 0); 55 + 56 + return `${formatted} ${SIZE_UNITS[exponent]}`; 57 + } 58 + 59 + const MINUTE_MS = 60_000; 60 + const HOUR_MS = 3_600_000; 61 + const DAY_MS = 86_400_000; 62 + 63 + /** Format an ISO datetime as a relative or short date string. */ 64 + export function formatRelativeDate(iso: string): string { 65 + const then = new Date(iso); 66 + const now = Date.now(); 67 + const delta = now - then.getTime(); 68 + 69 + if (delta < MINUTE_MS) return "Just now"; 70 + if (delta < HOUR_MS) { 71 + const minutes = Math.floor(delta / MINUTE_MS); 72 + return `${minutes} min ago`; 73 + } 74 + if (delta < DAY_MS) { 75 + const hours = Math.floor(delta / HOUR_MS); 76 + return `${hours} ${hours === 1 ? "hour" : "hours"} ago`; 77 + } 78 + if (delta < 2 * DAY_MS) return "Yesterday"; 79 + if (delta < 7 * DAY_MS) { 80 + const days = Math.floor(delta / DAY_MS); 81 + return `${days} days ago`; 82 + } 83 + 84 + return then.toLocaleDateString("en-GB", { day: "numeric", month: "short" }); 85 + } 86 + 87 + const DOCUMENT_COLLECTION = "app.opake.document"; 88 + const DIRECTORY_COLLECTION = "app.opake.directory"; 89 + 90 + /** Determine item kind from an AT-URI's collection segment. */ 91 + export function entryKindFromUri(uri: string): "file" | "folder" { 92 + if (uri.includes(DIRECTORY_COLLECTION)) return "folder"; 93 + if (uri.includes(DOCUMENT_COLLECTION)) return "file"; 94 + return "file"; 95 + }
+45 -45
web/src/lib/indexeddb-storage.ts web/src/lib/indexeddbStorage.ts
··· 1 1 // IndexedDB-backed Storage implementation using Dexie.js. 2 2 // Mirrors: crates/opake-cli/src/config.rs — FileStorage (but for the browser) 3 3 4 - import Dexie, { type EntityTable } from "dexie" 5 - import type { Config, Identity, Session } from "./storage-types" 6 - import { type Storage, StorageError, sanitizeDid } from "./storage" 4 + import Dexie, { type EntityTable } from "dexie"; 5 + import type { Config, Identity, Session } from "./storageTypes"; 6 + import { type Storage, StorageError, sanitizeDid } from "./storage"; 7 7 8 - const CONFIG_KEY = "global" 8 + const CONFIG_KEY = "global"; 9 9 10 10 interface ConfigRow { 11 - key: string 12 - value: Config 11 + key: string; 12 + value: Config; 13 13 } 14 14 15 15 interface IdentityRow { 16 - did: string 17 - value: Identity 16 + did: string; 17 + value: Identity; 18 18 } 19 19 20 20 interface SessionRow { 21 - did: string 22 - value: Session 21 + did: string; 22 + value: Session; 23 23 } 24 24 25 25 class OpakeDatabase extends Dexie { 26 - readonly configs!: Readonly<EntityTable<ConfigRow, "key">> 27 - readonly identities!: Readonly<EntityTable<IdentityRow, "did">> 28 - readonly sessions!: Readonly<EntityTable<SessionRow, "did">> 26 + readonly configs!: Readonly<EntityTable<ConfigRow, "key">>; 27 + readonly identities!: Readonly<EntityTable<IdentityRow, "did">>; 28 + readonly sessions!: Readonly<EntityTable<SessionRow, "did">>; 29 29 30 30 constructor(name = "opake") { 31 - super(name) 31 + super(name); 32 32 this.version(1).stores({ 33 33 configs: "key", 34 34 identities: "did", 35 35 sessions: "did", 36 - }) 36 + }); 37 37 } 38 38 } 39 39 40 40 export class IndexedDbStorage implements Storage { 41 - private readonly db: Readonly<OpakeDatabase> 41 + private readonly db: Readonly<OpakeDatabase>; 42 42 43 43 constructor(dbName = "opake") { 44 - this.db = new OpakeDatabase(dbName) 44 + this.db = new OpakeDatabase(dbName); 45 45 } 46 46 47 47 async loadConfig(): Promise<Config> { 48 - const row = await this.db.configs.get(CONFIG_KEY) 48 + const row = await this.db.configs.get(CONFIG_KEY); 49 49 if (!row) { 50 - throw new StorageError("no config found — log in first") 50 + throw new StorageError("no config found — log in first"); 51 51 } 52 - return row.value 52 + return row.value; 53 53 } 54 54 55 55 async saveConfig(config: Config): Promise<void> { 56 - await this.db.configs.put({ key: CONFIG_KEY, value: config }) 56 + await this.db.configs.put({ key: CONFIG_KEY, value: config }); 57 57 } 58 58 59 59 async loadIdentity(did: string): Promise<Identity> { 60 - const key = sanitizeDid(did) 61 - const row = await this.db.identities.get(key) 60 + const key = sanitizeDid(did); 61 + const row = await this.db.identities.get(key); 62 62 if (!row) { 63 - throw new StorageError(`no identity for ${did} — log in first`) 63 + throw new StorageError(`no identity for ${did} — log in first`); 64 64 } 65 - return row.value 65 + return row.value; 66 66 } 67 67 68 68 async saveIdentity(did: string, identity: Identity): Promise<void> { 69 - const key = sanitizeDid(did) 70 - await this.db.identities.put({ did: key, value: identity }) 69 + const key = sanitizeDid(did); 70 + await this.db.identities.put({ did: key, value: identity }); 71 71 } 72 72 73 73 async loadSession(did: string): Promise<Session> { 74 - const key = sanitizeDid(did) 75 - const row = await this.db.sessions.get(key) 74 + const key = sanitizeDid(did); 75 + const row = await this.db.sessions.get(key); 76 76 if (!row) { 77 - throw new StorageError(`no session for ${did} — log in first`) 77 + throw new StorageError(`no session for ${did} — log in first`); 78 78 } 79 - return row.value 79 + return row.value; 80 80 } 81 81 82 82 async saveSession(did: string, session: Session): Promise<void> { 83 - const key = sanitizeDid(did) 84 - await this.db.sessions.put({ did: key, value: session }) 83 + const key = sanitizeDid(did); 84 + await this.db.sessions.put({ did: key, value: session }); 85 85 } 86 86 87 87 async removeAccount(did: string): Promise<void> { 88 - const config = await this.loadConfig() 88 + const config = await this.loadConfig(); 89 89 const remainingAccounts = Object.fromEntries( 90 90 Object.entries(config.accounts).filter(([key]) => key !== did), 91 - ) 92 - const remaining = Object.keys(remainingAccounts) 91 + ); 92 + const remaining = Object.keys(remainingAccounts); 93 93 const updatedConfig: Config = { 94 94 ...config, 95 95 accounts: remainingAccounts, ··· 99 99 ? remaining[0] 100 100 : null 101 101 : config.defaultDid, 102 - } 103 - const key = sanitizeDid(did) 102 + }; 103 + const key = sanitizeDid(did); 104 104 await this.db.transaction( 105 105 "rw", 106 106 [this.db.configs, this.db.identities, this.db.sessions], 107 107 async () => { 108 - await this.db.configs.put({ key: CONFIG_KEY, value: updatedConfig }) 109 - await this.db.identities.delete(key) 110 - await this.db.sessions.delete(key) 108 + await this.db.configs.put({ key: CONFIG_KEY, value: updatedConfig }); 109 + await this.db.identities.delete(key); 110 + await this.db.sessions.delete(key); 111 111 }, 112 - ) 112 + ); 113 113 } 114 114 115 115 /** Close the database connection. Useful for test cleanup. */ 116 116 close(): void { 117 - this.db.close() 117 + this.db.close(); 118 118 } 119 119 120 120 /** Delete the entire database. Useful for test cleanup. */ 121 121 async destroy(): Promise<void> { 122 - this.db.close() 123 - await this.db.delete() 122 + this.db.close(); 123 + await this.db.delete(); 124 124 } 125 125 }
+120 -120
web/src/lib/oauth.ts
··· 3 3 // HTTP calls use plain fetch. Crypto (DPoP proofs, PKCE, keypair gen) is 4 4 // delegated to the WASM worker via the CryptoWorker type. 5 5 6 - import type { Remote } from "comlink" 7 - import type { CryptoApi } from "@/workers/crypto.worker" 8 - import type { DpopKeyPair } from "@/lib/crypto-types" 6 + import type { Remote } from "comlink"; 7 + import type { CryptoApi } from "@/workers/crypto.worker"; 8 + import type { DpopKeyPair } from "@/lib/cryptoTypes"; 9 9 10 - type CryptoWorker = Remote<CryptoApi> 10 + type CryptoWorker = Remote<CryptoApi>; 11 11 12 12 // --------------------------------------------------------------------------- 13 13 // Types 14 14 // --------------------------------------------------------------------------- 15 15 16 16 export interface AuthorizationServerMetadata { 17 - issuer: string 18 - authorization_endpoint: string 19 - token_endpoint: string 20 - pushed_authorization_request_endpoint?: string 21 - scopes_supported: string[] 22 - response_types_supported: string[] 23 - grant_types_supported: string[] 24 - code_challenge_methods_supported: string[] 25 - dpop_signing_alg_values_supported: string[] 26 - token_endpoint_auth_methods_supported: string[] 27 - require_pushed_authorization_requests: boolean 17 + issuer: string; 18 + authorization_endpoint: string; 19 + token_endpoint: string; 20 + pushed_authorization_request_endpoint?: string; 21 + scopes_supported: string[]; 22 + response_types_supported: string[]; 23 + grant_types_supported: string[]; 24 + code_challenge_methods_supported: string[]; 25 + dpop_signing_alg_values_supported: string[]; 26 + token_endpoint_auth_methods_supported: string[]; 27 + require_pushed_authorization_requests: boolean; 28 28 } 29 29 30 30 export interface TokenResponse { 31 - access_token: string 32 - token_type: string 33 - refresh_token?: string 34 - expires_in?: number 35 - scope?: string 36 - sub?: string 31 + access_token: string; 32 + token_type: string; 33 + refresh_token?: string; 34 + expires_in?: number; 35 + scope?: string; 36 + sub?: string; 37 37 } 38 38 39 39 export interface OAuthPendingState { 40 - pdsUrl: string 41 - handle: string 42 - dpopKey: DpopKeyPair 43 - pkceVerifier: string 44 - csrfState: string 45 - tokenEndpoint: string 46 - clientId: string 47 - dpopNonce: string | null 40 + pdsUrl: string; 41 + handle: string; 42 + dpopKey: DpopKeyPair; 43 + pkceVerifier: string; 44 + csrfState: string; 45 + tokenEndpoint: string; 46 + clientId: string; 47 + dpopNonce: string | null; 48 48 } 49 49 50 - const PENDING_STATE_KEY = "opake:oauth_pending" 51 - const BSKY_PUBLIC_API = "https://public.api.bsky.app" 50 + const PENDING_STATE_KEY = "opake:oauth_pending"; 51 + const BSKY_PUBLIC_API = "https://public.api.bsky.app"; 52 52 53 53 // --------------------------------------------------------------------------- 54 54 // Handle → PDS resolution 55 55 // --------------------------------------------------------------------------- 56 56 57 57 export async function resolveHandleToPds(handle: string): Promise<{ did: string; pdsUrl: string }> { 58 - const resolveUrl = `${BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` 59 - const response = await fetch(resolveUrl) 58 + const resolveUrl = `${BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 59 + const response = await fetch(resolveUrl); 60 60 if (!response.ok) { 61 - throw new Error(`Failed to resolve handle "${handle}": HTTP ${response.status}`) 61 + throw new Error(`Failed to resolve handle "${handle}": HTTP ${response.status}`); 62 62 } 63 - const { did } = (await response.json()) as { did: string } 63 + const { did } = (await response.json()) as { did: string }; 64 64 65 - const pdsUrl = await pdsUrlFromDid(did) 66 - return { did, pdsUrl } 65 + const pdsUrl = await pdsUrlFromDid(did); 66 + return { did, pdsUrl }; 67 67 } 68 68 69 69 async function pdsUrlFromDid(did: string): Promise<string> { ··· 71 71 ? `https://plc.directory/${did}` 72 72 : did.startsWith("did:web:") 73 73 ? `https://${did.slice("did:web:".length)}/.well-known/did.json` 74 - : null 74 + : null; 75 75 76 - if (!docUrl) throw new Error(`Unsupported DID method: ${did}`) 76 + if (!docUrl) throw new Error(`Unsupported DID method: ${did}`); 77 77 78 - const response = await fetch(docUrl) 78 + const response = await fetch(docUrl); 79 79 if (!response.ok) { 80 - throw new Error(`Failed to fetch DID document for ${did}: HTTP ${response.status}`) 80 + throw new Error(`Failed to fetch DID document for ${did}: HTTP ${response.status}`); 81 81 } 82 82 83 83 const doc = (await response.json()) as { 84 - service?: { id: string; serviceEndpoint: string }[] 85 - } 84 + service?: { id: string; serviceEndpoint: string }[]; 85 + }; 86 86 87 - const pds = doc.service?.find((s) => s.id === "#atproto_pds") 88 - if (!pds) throw new Error(`No #atproto_pds service in DID document for ${did}`) 87 + const pds = doc.service?.find((s) => s.id === "#atproto_pds"); 88 + if (!pds) throw new Error(`No #atproto_pds service in DID document for ${did}`); 89 89 90 - return pds.serviceEndpoint 90 + return pds.serviceEndpoint; 91 91 } 92 92 93 93 // --------------------------------------------------------------------------- ··· 97 97 export async function discoverAuthorizationServer( 98 98 pdsUrl: string, 99 99 ): Promise<AuthorizationServerMetadata> { 100 - const base = pdsUrl.replace(/\/$/, "") 100 + const base = pdsUrl.replace(/\/$/, ""); 101 101 102 - const prmResponse = await fetch(`${base}/.well-known/oauth-protected-resource`) 102 + const prmResponse = await fetch(`${base}/.well-known/oauth-protected-resource`); 103 103 if (!prmResponse.ok) { 104 - throw new Error(`PDS does not support OAuth (HTTP ${prmResponse.status})`) 104 + throw new Error(`PDS does not support OAuth (HTTP ${prmResponse.status})`); 105 105 } 106 106 const prm = (await prmResponse.json()) as { 107 - authorization_servers?: string[] 108 - } 107 + authorization_servers?: string[]; 108 + }; 109 109 110 - const asUrl = prm.authorization_servers?.[0] 111 - if (!asUrl) throw new Error("No authorization servers in protected resource metadata") 110 + const asUrl = prm.authorization_servers?.[0]; 111 + if (!asUrl) throw new Error("No authorization servers in protected resource metadata"); 112 112 113 - const asBase = asUrl.replace(/\/$/, "") 114 - const asmResponse = await fetch(`${asBase}/.well-known/oauth-authorization-server`) 113 + const asBase = asUrl.replace(/\/$/, ""); 114 + const asmResponse = await fetch(`${asBase}/.well-known/oauth-authorization-server`); 115 115 if (!asmResponse.ok) { 116 - throw new Error(`Failed to fetch AS metadata: HTTP ${asmResponse.status}`) 116 + throw new Error(`Failed to fetch AS metadata: HTTP ${asmResponse.status}`); 117 117 } 118 118 119 - return (await asmResponse.json()) as AuthorizationServerMetadata 119 + return (await asmResponse.json()) as AuthorizationServerMetadata; 120 120 } 121 121 122 122 // --------------------------------------------------------------------------- ··· 124 124 // --------------------------------------------------------------------------- 125 125 126 126 export function buildClientId(redirectUri: string): string { 127 - return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}` 127 + return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}`; 128 128 } 129 129 130 130 export function buildRedirectUri(): string { 131 - return `${window.location.origin}/devices/oauth-callback` 131 + return `${window.location.origin}/devices/oauth-callback`; 132 132 } 133 133 134 134 // --------------------------------------------------------------------------- ··· 144 144 accessToken: string | null, 145 145 worker: CryptoWorker, 146 146 ): Promise<{ response: Response; dpopNonce: string | null }> { 147 - console.debug("[dpop] creating proof for", method, url) 148 - const timestamp = Math.floor(Date.now() / 1000) 147 + console.debug("[dpop] creating proof for", method, url); 148 + const timestamp = Math.floor(Date.now() / 1000); 149 149 const proof = await worker.createDpopProof( 150 150 dpopKey, 151 151 method, ··· 153 153 timestamp, 154 154 dpopNonce, 155 155 accessToken, 156 - ) 157 - console.debug("[dpop] proof created, sending request") 156 + ); 157 + console.debug("[dpop] proof created, sending request"); 158 158 159 159 const headers: Record<string, string> = { 160 160 "Content-Type": "application/x-www-form-urlencoded", 161 161 DPoP: proof, 162 - } 162 + }; 163 163 if (accessToken) { 164 - headers.Authorization = `DPoP ${accessToken}` 164 + headers.Authorization = `DPoP ${accessToken}`; 165 165 } 166 166 167 - let response = await fetch(url, { method, headers, body: body.toString() }) 168 - console.debug("[dpop] response:", response.status) 169 - let nonce = response.headers.get("dpop-nonce") ?? dpopNonce 167 + let response = await fetch(url, { method, headers, body: body.toString() }); 168 + console.debug("[dpop] response:", response.status); 169 + let nonce = response.headers.get("dpop-nonce") ?? dpopNonce; 170 170 171 171 // Retry on use_dpop_nonce 172 172 if (response.status === 400) { ··· 174 174 .clone() 175 175 .json() 176 176 .catch(() => null)) as { 177 - error?: string 178 - error_description?: string 179 - } | null 180 - console.debug("[dpop] 400 error body:", errorBody) 177 + error?: string; 178 + error_description?: string; 179 + } | null; 180 + console.debug("[dpop] 400 error body:", errorBody); 181 181 182 182 if (errorBody?.error === "use_dpop_nonce" && nonce) { 183 - console.debug("[dpop] retrying with server nonce") 183 + console.debug("[dpop] retrying with server nonce"); 184 184 const retryProof = await worker.createDpopProof( 185 185 dpopKey, 186 186 method, ··· 188 188 timestamp, 189 189 nonce, 190 190 accessToken, 191 - ) 192 - headers.DPoP = retryProof 193 - response = await fetch(url, { method, headers, body: body.toString() }) 194 - console.debug("[dpop] retry response:", response.status) 195 - nonce = response.headers.get("dpop-nonce") ?? nonce 191 + ); 192 + headers.DPoP = retryProof; 193 + response = await fetch(url, { method, headers, body: body.toString() }); 194 + console.debug("[dpop] retry response:", response.status); 195 + nonce = response.headers.get("dpop-nonce") ?? nonce; 196 196 } 197 197 } 198 198 199 - return { response, dpopNonce: nonce } 199 + return { response, dpopNonce: nonce }; 200 200 } 201 201 202 202 // --------------------------------------------------------------------------- ··· 221 221 state, 222 222 code_challenge: pkceChallenge, 223 223 code_challenge_method: "S256", 224 - }) 224 + }); 225 225 226 226 const { response, dpopNonce: nonce } = await fetchWithDpop( 227 227 parEndpoint, ··· 231 231 dpopNonce, 232 232 null, 233 233 worker, 234 - ) 234 + ); 235 235 236 236 if (!response.ok) { 237 237 const err = (await response.json().catch(() => ({}))) as { 238 - error?: string 239 - error_description?: string 240 - } 238 + error?: string; 239 + error_description?: string; 240 + }; 241 241 throw new Error( 242 242 `PAR failed: ${err.error ?? "unknown"}: ${err.error_description ?? `HTTP ${response.status}`}`, 243 - ) 243 + ); 244 244 } 245 245 246 - const par = (await response.json()) as { request_uri: string; expires_in: number } 247 - return { requestUri: par.request_uri, expiresIn: par.expires_in, dpopNonce: nonce } 246 + const par = (await response.json()) as { request_uri: string; expires_in: number }; 247 + return { requestUri: par.request_uri, expiresIn: par.expires_in, dpopNonce: nonce }; 248 248 } 249 249 250 250 // --------------------------------------------------------------------------- ··· 256 256 clientId: string, 257 257 requestUri: string, 258 258 ): string { 259 - return `${authorizationEndpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(requestUri)}` 259 + return `${authorizationEndpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(requestUri)}`; 260 260 } 261 261 262 262 // --------------------------------------------------------------------------- ··· 279 279 code, 280 280 redirect_uri: redirectUri, 281 281 code_verifier: pkceVerifier, 282 - }) 282 + }); 283 283 284 284 const { response, dpopNonce: nonce } = await fetchWithDpop( 285 285 tokenEndpoint, ··· 289 289 dpopNonce, 290 290 null, 291 291 worker, 292 - ) 292 + ); 293 293 294 294 if (!response.ok) { 295 295 const err = (await response.json().catch(() => ({}))) as { 296 - error?: string 297 - error_description?: string 298 - } 296 + error?: string; 297 + error_description?: string; 298 + }; 299 299 throw new Error( 300 300 `Token exchange failed: ${err.error ?? "unknown"}: ${err.error_description ?? `HTTP ${response.status}`}`, 301 - ) 301 + ); 302 302 } 303 303 304 - const tokenResponse = (await response.json()) as TokenResponse 304 + const tokenResponse = (await response.json()) as TokenResponse; 305 305 306 306 if (tokenResponse.token_type.toLowerCase() !== "dpop") { 307 - throw new Error(`Expected token_type "DPoP", got "${tokenResponse.token_type}"`) 307 + throw new Error(`Expected token_type "DPoP", got "${tokenResponse.token_type}"`); 308 308 } 309 309 310 - return { tokenResponse, dpopNonce: nonce } 310 + return { tokenResponse, dpopNonce: nonce }; 311 311 } 312 312 313 313 // --------------------------------------------------------------------------- ··· 324 324 dpopNonce: string | null, 325 325 worker: CryptoWorker, 326 326 ): Promise<void> { 327 - const base = pdsUrl.replace(/\/$/, "") 328 - const url = `${base}/xrpc/com.atproto.repo.putRecord` 327 + const base = pdsUrl.replace(/\/$/, ""); 328 + const url = `${base}/xrpc/com.atproto.repo.putRecord`; 329 329 330 330 const record: Readonly<Record<string, unknown>> = { 331 331 $type: "app.opake.publicKey", ··· 334 334 publicKey: { $bytes: publicKey }, 335 335 createdAt: new Date().toISOString(), 336 336 ...(verifyKey ? { signingKey: { $bytes: verifyKey }, signingAlgo: "ed25519" } : {}), 337 - } 337 + }; 338 338 339 339 const jsonBody = JSON.stringify({ 340 340 repo: did, 341 341 collection: "app.opake.publicKey", 342 342 rkey: "self", 343 343 record, 344 - }) 344 + }); 345 345 346 346 const makeHeaders = async (nonce: string | null): Promise<Record<string, string>> => { 347 - const timestamp = Math.floor(Date.now() / 1000) 348 - const proof = await worker.createDpopProof(dpopKey, "POST", url, timestamp, nonce, accessToken) 347 + const timestamp = Math.floor(Date.now() / 1000); 348 + const proof = await worker.createDpopProof(dpopKey, "POST", url, timestamp, nonce, accessToken); 349 349 return { 350 350 "Content-Type": "application/json", 351 351 Authorization: `DPoP ${accessToken}`, 352 352 DPoP: proof, 353 - } 354 - } 353 + }; 354 + }; 355 355 356 - let headers = await makeHeaders(dpopNonce) 357 - let response = await fetch(url, { method: "POST", headers, body: jsonBody }) 356 + let headers = await makeHeaders(dpopNonce); 357 + let response = await fetch(url, { method: "POST", headers, body: jsonBody }); 358 358 359 359 // DPoP nonce retry — PDS nonce differs from AS nonce 360 360 if ((response.status === 401 || response.status === 400) && response.headers.has("dpop-nonce")) { 361 - const nonce = response.headers.get("dpop-nonce") 362 - headers = await makeHeaders(nonce) 363 - response = await fetch(url, { method: "POST", headers, body: jsonBody }) 361 + const nonce = response.headers.get("dpop-nonce"); 362 + headers = await makeHeaders(nonce); 363 + response = await fetch(url, { method: "POST", headers, body: jsonBody }); 364 364 } 365 365 366 366 if (!response.ok) { 367 - const body = await response.text().catch(() => "") 368 - throw new Error(`Failed to publish public key: HTTP ${response.status} ${body}`) 367 + const body = await response.text().catch(() => ""); 368 + throw new Error(`Failed to publish public key: HTTP ${response.status} ${body}`); 369 369 } 370 370 } 371 371 ··· 374 374 // --------------------------------------------------------------------------- 375 375 376 376 export function savePendingState(state: OAuthPendingState): void { 377 - sessionStorage.setItem(PENDING_STATE_KEY, JSON.stringify(state)) 377 + sessionStorage.setItem(PENDING_STATE_KEY, JSON.stringify(state)); 378 378 } 379 379 380 380 export function loadPendingState(): OAuthPendingState | null { 381 - const raw = sessionStorage.getItem(PENDING_STATE_KEY) 382 - if (!raw) return null 383 - return JSON.parse(raw) as OAuthPendingState 381 + const raw = sessionStorage.getItem(PENDING_STATE_KEY); 382 + if (!raw) return null; 383 + return JSON.parse(raw) as OAuthPendingState; 384 384 } 385 385 386 386 export function clearPendingState(): void { 387 - sessionStorage.removeItem(PENDING_STATE_KEY) 387 + sessionStorage.removeItem(PENDING_STATE_KEY); 388 388 } 389 389 390 390 // --------------------------------------------------------------------------- ··· 392 392 // --------------------------------------------------------------------------- 393 393 394 394 export function generateCsrfState(): string { 395 - const bytes = new Uint8Array(16) 396 - crypto.getRandomValues(bytes) 395 + const bytes = new Uint8Array(16); 396 + crypto.getRandomValues(bytes); 397 397 return btoa(String.fromCharCode(...bytes)) 398 398 .replaceAll("+", "-") 399 399 .replaceAll("/", "_") 400 - .replaceAll("=", "") 400 + .replaceAll("=", ""); 401 401 }
+2 -2
web/src/lib/pairing.ts
··· 3 3 4 4 import type { Remote } from "comlink"; 5 5 import type { CryptoApi } from "@/workers/crypto.worker"; 6 - import type { WrappedKey, AtBytes } from "@/lib/crypto-types"; 7 - import type { Identity, Session } from "@/lib/storage-types"; 6 + import type { WrappedKey, AtBytes } from "@/lib/cryptoTypes"; 7 + import type { Identity, Session } from "@/lib/storageTypes"; 8 8 import { authenticatedXrpc } from "@/lib/api"; 9 9 import { 10 10 uint8ArrayToBase64,
+117
web/src/lib/pdsTypes.ts
··· 1 + // TypeScript equivalents of PDS record types returned by listRecords / getRecord. 2 + // Mirrors: crates/opake-core/src/records/ 3 + 4 + import type { AtBytes, WrappedKey } from "./cryptoTypes"; 5 + 6 + // --------------------------------------------------------------------------- 7 + // Generic listRecords response 8 + // --------------------------------------------------------------------------- 9 + 10 + export interface PdsRecord<T> { 11 + readonly uri: string; 12 + readonly cid: string; 13 + readonly value: T; 14 + } 15 + 16 + export interface ListRecordsResponse<T> { 17 + readonly records: readonly PdsRecord<T>[]; 18 + readonly cursor?: string; 19 + } 20 + 21 + // --------------------------------------------------------------------------- 22 + // Encryption envelope (shared by documents, directories, keyrings, grants) 23 + // --------------------------------------------------------------------------- 24 + 25 + export interface EncryptedMetadataEnvelope { 26 + readonly ciphertext: AtBytes; 27 + readonly nonce: AtBytes; 28 + } 29 + 30 + export interface EncryptionEnvelope { 31 + readonly algo: string; 32 + readonly nonce: AtBytes; 33 + readonly keys: readonly WrappedKey[]; 34 + } 35 + 36 + interface DirectEncryption { 37 + readonly $type: "app.opake.document#directEncryption"; 38 + readonly envelope: EncryptionEnvelope; 39 + } 40 + 41 + interface KeyringEncryption { 42 + readonly $type: "app.opake.document#keyringEncryption"; 43 + readonly keyringRef: { 44 + readonly keyring: string; 45 + readonly wrappedContentKey: AtBytes; 46 + readonly rotation: number; 47 + }; 48 + readonly algo: string; 49 + readonly nonce: AtBytes; 50 + } 51 + 52 + export type Encryption = DirectEncryption | KeyringEncryption; 53 + 54 + // --------------------------------------------------------------------------- 55 + // app.opake.document 56 + // --------------------------------------------------------------------------- 57 + 58 + export interface BlobRef { 59 + readonly $type: "blob"; 60 + readonly ref: { readonly $link: string }; 61 + readonly mimeType: string; 62 + readonly size: number; 63 + } 64 + 65 + export interface DocumentRecord { 66 + readonly opakeVersion: number; 67 + readonly blob: BlobRef; 68 + readonly encryption: Encryption; 69 + readonly encryptedMetadata: EncryptedMetadataEnvelope; 70 + readonly visibility: string | null; 71 + readonly createdAt: string; 72 + readonly modifiedAt: string | null; 73 + } 74 + 75 + // --------------------------------------------------------------------------- 76 + // app.opake.directory 77 + // --------------------------------------------------------------------------- 78 + 79 + export interface DirectoryRecord { 80 + readonly opakeVersion: number; 81 + readonly encryption: Encryption; 82 + readonly encryptedMetadata: EncryptedMetadataEnvelope; 83 + readonly entries: string[]; 84 + readonly createdAt: string; 85 + readonly modifiedAt: string | null; 86 + } 87 + 88 + // --------------------------------------------------------------------------- 89 + // Decrypted metadata (result of worker decryption) 90 + // --------------------------------------------------------------------------- 91 + 92 + export interface DocumentMetadata { 93 + readonly name: string; 94 + readonly mimeType?: string; 95 + readonly size?: number; 96 + readonly tags?: string[]; 97 + readonly description?: string; 98 + } 99 + 100 + export interface DirectoryMetadata { 101 + readonly name: string; 102 + readonly description?: string; 103 + } 104 + 105 + // --------------------------------------------------------------------------- 106 + // DirectoryTree snapshot (returned by WASM DirectoryTreeHandle.snapshot()) 107 + // --------------------------------------------------------------------------- 108 + 109 + export interface DirectorySnapshotEntry { 110 + readonly name: string; 111 + readonly entries: readonly string[]; 112 + } 113 + 114 + export interface DirectoryTreeSnapshot { 115 + readonly rootUri: string | null; 116 + readonly directories: Readonly<Record<string, DirectorySnapshotEntry>>; 117 + }
-49
web/src/lib/storage-types.ts
··· 1 - // TypeScript equivalents of opake-core storage types. 2 - // Mirrors: crates/opake-core/src/storage.rs 3 - 4 - import type { DpopKeyPair } from "./crypto-types" 5 - 6 - export interface Config { 7 - readonly defaultDid: string | null 8 - readonly accounts: Readonly<Record<string, AccountConfig>> 9 - readonly appviewUrl: string | null 10 - } 11 - 12 - export interface AccountConfig { 13 - readonly pdsUrl: string 14 - readonly handle: string 15 - } 16 - 17 - export interface Identity { 18 - readonly did: string 19 - readonly public_key: string // base64 X25519 20 - readonly private_key: string // base64 X25519 21 - readonly signing_key: string | null // base64 Ed25519 22 - readonly verify_key: string | null // base64 Ed25519 23 - } 24 - 25 - // Mirrors: opake-core Session enum (client/xrpc/mod.rs) 26 - // Discriminated union — the `type` tag matches Rust's #[serde(tag = "type")] 27 - 28 - export interface LegacySession { 29 - readonly type: "legacy" 30 - readonly did: string 31 - readonly handle: string 32 - readonly accessJwt: string 33 - readonly refreshJwt: string 34 - } 35 - 36 - export interface OAuthSession { 37 - readonly type: "oauth" 38 - readonly did: string 39 - readonly handle: string 40 - accessToken: string 41 - refreshToken: string 42 - readonly dpopKey: DpopKeyPair 43 - readonly tokenEndpoint: string 44 - dpopNonce: string | null 45 - expiresAt: number | null 46 - readonly clientId: string 47 - } 48 - 49 - export type Session = LegacySession | OAuthSession
+11 -11
web/src/lib/storage.ts
··· 1 1 // Platform-agnostic storage contract. 2 2 // Mirrors: crates/opake-core/src/storage.rs — Storage trait 3 3 4 - import type { Config, Identity, Session } from "./storage-types" 4 + import type { Config, Identity, Session } from "./storageTypes"; 5 5 6 6 export interface Storage { 7 - loadConfig(): Promise<Config> 8 - saveConfig(config: Config): Promise<void> 9 - loadIdentity(did: string): Promise<Identity> 10 - saveIdentity(did: string, identity: Identity): Promise<void> 11 - loadSession(did: string): Promise<Session> 12 - saveSession(did: string, session: Session): Promise<void> 13 - removeAccount(did: string): Promise<void> 7 + loadConfig(): Promise<Config>; 8 + saveConfig(config: Config): Promise<void>; 9 + loadIdentity(did: string): Promise<Identity>; 10 + saveIdentity(did: string, identity: Identity): Promise<void>; 11 + loadSession(did: string): Promise<Session>; 12 + saveSession(did: string, session: Session): Promise<void>; 13 + removeAccount(did: string): Promise<void>; 14 14 } 15 15 16 16 export class StorageError extends Error { 17 17 constructor(message: string) { 18 - super(message) 19 - this.name = "StorageError" 18 + super(message); 19 + this.name = "StorageError"; 20 20 } 21 21 } 22 22 23 23 /** `did:plc:abc` → `did_plc_abc` — mirrors `sanitize_did` in opake-core. */ 24 24 export function sanitizeDid(did: string): string { 25 - return did.replaceAll(":", "_") 25 + return did.replaceAll(":", "_"); 26 26 }
+49
web/src/lib/storageTypes.ts
··· 1 + // TypeScript equivalents of opake-core storage types. 2 + // Mirrors: crates/opake-core/src/storage.rs 3 + 4 + import type { DpopKeyPair } from "./cryptoTypes"; 5 + 6 + export interface Config { 7 + readonly defaultDid: string | null; 8 + readonly accounts: Readonly<Record<string, AccountConfig>>; 9 + readonly appviewUrl: string | null; 10 + } 11 + 12 + export interface AccountConfig { 13 + readonly pdsUrl: string; 14 + readonly handle: string; 15 + } 16 + 17 + export interface Identity { 18 + readonly did: string; 19 + readonly public_key: string; // base64 X25519 20 + readonly private_key: string; // base64 X25519 21 + readonly signing_key: string | null; // base64 Ed25519 22 + readonly verify_key: string | null; // base64 Ed25519 23 + } 24 + 25 + // Mirrors: opake-core Session enum (client/xrpc/mod.rs) 26 + // Discriminated union — the `type` tag matches Rust's #[serde(tag = "type")] 27 + 28 + export interface LegacySession { 29 + readonly type: "legacy"; 30 + readonly did: string; 31 + readonly handle: string; 32 + readonly accessJwt: string; 33 + readonly refreshJwt: string; 34 + } 35 + 36 + export interface OAuthSession { 37 + readonly type: "oauth"; 38 + readonly did: string; 39 + readonly handle: string; 40 + accessToken: string; 41 + refreshToken: string; 42 + readonly dpopKey: DpopKeyPair; 43 + readonly tokenEndpoint: string; 44 + dpopNonce: string | null; 45 + expiresAt: number | null; 46 + readonly clientId: string; 47 + } 48 + 49 + export type Session = LegacySession | OAuthSession;
+12 -12
web/src/lib/worker.ts
··· 1 1 // Shared crypto worker singleton. 2 2 // One Comlink-wrapped WASM worker for the entire app. 3 3 4 - import { wrap, type Remote } from "comlink" 5 - import type { CryptoApi } from "@/workers/crypto.worker" 4 + import { wrap, type Remote } from "comlink"; 5 + import type { CryptoApi } from "@/workers/crypto.worker"; 6 6 7 7 function createWorker(): Remote<CryptoApi> { 8 8 const raw = new Worker(new URL("../workers/crypto.worker.ts", import.meta.url), { 9 9 type: "module", 10 - }) 10 + }); 11 11 raw.addEventListener("error", (e) => { 12 - console.error("[worker] error:", e.message, e.filename, e.lineno) 13 - }) 14 - return wrap<CryptoApi>(raw) 12 + console.error("[worker] error:", e.message, e.filename, e.lineno); 13 + }); 14 + return wrap<CryptoApi>(raw); 15 15 } 16 16 17 17 const memo = /* @__PURE__ */ (() => { 18 - const ref = { current: null as Remote<CryptoApi> | null } 18 + const ref = { current: null as Remote<CryptoApi> | null }; 19 19 return () => { 20 - ref.current ??= createWorker() 21 - return ref.current 22 - } 23 - })() 20 + ref.current ??= createWorker(); 21 + return ref.current; 22 + }; 23 + })(); 24 24 25 25 export function getCryptoWorker(): Remote<CryptoApi> { 26 - return memo() 26 + return memo(); 27 27 }
+225 -21
web/src/routeTree.gen.ts
··· 9 9 // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 - import { Route as CabinetRouteImport } from './routes/cabinet' 13 12 import { Route as DevicesRouteRouteImport } from './routes/devices/route' 13 + import { Route as CabinetRouteRouteImport } from './routes/cabinet/route' 14 14 import { Route as IndexRouteImport } from './routes/index' 15 15 import { Route as DevicesIndexRouteImport } from './routes/devices/index' 16 + import { Route as CabinetIndexRouteImport } from './routes/cabinet/index' 16 17 import { Route as DevicesOauthCallbackRouteImport } from './routes/devices/oauth-callback' 17 18 import { Route as DevicesLoginRouteImport } from './routes/devices/login' 18 19 import { Route as DevicesCliCallbackRouteImport } from './routes/devices/cli-callback' 20 + import { Route as CabinetTrashRouteImport } from './routes/cabinet/trash' 21 + import { Route as CabinetSharedRouteImport } from './routes/cabinet/shared' 22 + import { Route as CabinetSettingsRouteImport } from './routes/cabinet/settings' 23 + import { Route as CabinetEncryptedRouteImport } from './routes/cabinet/encrypted' 24 + import { Route as CabinetDocsRouteImport } from './routes/cabinet/docs' 25 + import { Route as CabinetFilesRouteRouteImport } from './routes/cabinet/files/route' 26 + import { Route as CabinetFilesIndexRouteImport } from './routes/cabinet/files/index' 19 27 import { Route as DevicesPairRequestRouteImport } from './routes/devices/pair.request' 20 28 import { Route as DevicesPairAcceptRouteImport } from './routes/devices/pair.accept' 29 + import { Route as CabinetFilesSplatRouteImport } from './routes/cabinet/files/$' 21 30 22 - const CabinetRoute = CabinetRouteImport.update({ 23 - id: '/cabinet', 24 - path: '/cabinet', 25 - getParentRoute: () => rootRouteImport, 26 - } as any) 27 31 const DevicesRouteRoute = DevicesRouteRouteImport.update({ 28 32 id: '/devices', 29 33 path: '/devices', 30 34 getParentRoute: () => rootRouteImport, 31 35 } as any) 36 + const CabinetRouteRoute = CabinetRouteRouteImport.update({ 37 + id: '/cabinet', 38 + path: '/cabinet', 39 + getParentRoute: () => rootRouteImport, 40 + } as any) 32 41 const IndexRoute = IndexRouteImport.update({ 33 42 id: '/', 34 43 path: '/', ··· 39 48 path: '/', 40 49 getParentRoute: () => DevicesRouteRoute, 41 50 } as any) 51 + const CabinetIndexRoute = CabinetIndexRouteImport.update({ 52 + id: '/', 53 + path: '/', 54 + getParentRoute: () => CabinetRouteRoute, 55 + } as any) 42 56 const DevicesOauthCallbackRoute = DevicesOauthCallbackRouteImport.update({ 43 57 id: '/oauth-callback', 44 58 path: '/oauth-callback', ··· 54 68 path: '/cli-callback', 55 69 getParentRoute: () => DevicesRouteRoute, 56 70 } as any) 71 + const CabinetTrashRoute = CabinetTrashRouteImport.update({ 72 + id: '/trash', 73 + path: '/trash', 74 + getParentRoute: () => CabinetRouteRoute, 75 + } as any) 76 + const CabinetSharedRoute = CabinetSharedRouteImport.update({ 77 + id: '/shared', 78 + path: '/shared', 79 + getParentRoute: () => CabinetRouteRoute, 80 + } as any) 81 + const CabinetSettingsRoute = CabinetSettingsRouteImport.update({ 82 + id: '/settings', 83 + path: '/settings', 84 + getParentRoute: () => CabinetRouteRoute, 85 + } as any) 86 + const CabinetEncryptedRoute = CabinetEncryptedRouteImport.update({ 87 + id: '/encrypted', 88 + path: '/encrypted', 89 + getParentRoute: () => CabinetRouteRoute, 90 + } as any) 91 + const CabinetDocsRoute = CabinetDocsRouteImport.update({ 92 + id: '/docs', 93 + path: '/docs', 94 + getParentRoute: () => CabinetRouteRoute, 95 + } as any) 96 + const CabinetFilesRouteRoute = CabinetFilesRouteRouteImport.update({ 97 + id: '/files', 98 + path: '/files', 99 + getParentRoute: () => CabinetRouteRoute, 100 + } as any) 101 + const CabinetFilesIndexRoute = CabinetFilesIndexRouteImport.update({ 102 + id: '/', 103 + path: '/', 104 + getParentRoute: () => CabinetFilesRouteRoute, 105 + } as any) 57 106 const DevicesPairRequestRoute = DevicesPairRequestRouteImport.update({ 58 107 id: '/pair/request', 59 108 path: '/pair/request', ··· 64 113 path: '/pair/accept', 65 114 getParentRoute: () => DevicesRouteRoute, 66 115 } as any) 116 + const CabinetFilesSplatRoute = CabinetFilesSplatRouteImport.update({ 117 + id: '/$', 118 + path: '/$', 119 + getParentRoute: () => CabinetFilesRouteRoute, 120 + } as any) 67 121 68 122 export interface FileRoutesByFullPath { 69 123 '/': typeof IndexRoute 124 + '/cabinet': typeof CabinetRouteRouteWithChildren 70 125 '/devices': typeof DevicesRouteRouteWithChildren 71 - '/cabinet': typeof CabinetRoute 126 + '/cabinet/files': typeof CabinetFilesRouteRouteWithChildren 127 + '/cabinet/docs': typeof CabinetDocsRoute 128 + '/cabinet/encrypted': typeof CabinetEncryptedRoute 129 + '/cabinet/settings': typeof CabinetSettingsRoute 130 + '/cabinet/shared': typeof CabinetSharedRoute 131 + '/cabinet/trash': typeof CabinetTrashRoute 72 132 '/devices/cli-callback': typeof DevicesCliCallbackRoute 73 133 '/devices/login': typeof DevicesLoginRoute 74 134 '/devices/oauth-callback': typeof DevicesOauthCallbackRoute 135 + '/cabinet/': typeof CabinetIndexRoute 75 136 '/devices/': typeof DevicesIndexRoute 137 + '/cabinet/files/$': typeof CabinetFilesSplatRoute 76 138 '/devices/pair/accept': typeof DevicesPairAcceptRoute 77 139 '/devices/pair/request': typeof DevicesPairRequestRoute 140 + '/cabinet/files/': typeof CabinetFilesIndexRoute 78 141 } 79 142 export interface FileRoutesByTo { 80 143 '/': typeof IndexRoute 81 - '/cabinet': typeof CabinetRoute 144 + '/cabinet/docs': typeof CabinetDocsRoute 145 + '/cabinet/encrypted': typeof CabinetEncryptedRoute 146 + '/cabinet/settings': typeof CabinetSettingsRoute 147 + '/cabinet/shared': typeof CabinetSharedRoute 148 + '/cabinet/trash': typeof CabinetTrashRoute 82 149 '/devices/cli-callback': typeof DevicesCliCallbackRoute 83 150 '/devices/login': typeof DevicesLoginRoute 84 151 '/devices/oauth-callback': typeof DevicesOauthCallbackRoute 152 + '/cabinet': typeof CabinetIndexRoute 85 153 '/devices': typeof DevicesIndexRoute 154 + '/cabinet/files/$': typeof CabinetFilesSplatRoute 86 155 '/devices/pair/accept': typeof DevicesPairAcceptRoute 87 156 '/devices/pair/request': typeof DevicesPairRequestRoute 157 + '/cabinet/files': typeof CabinetFilesIndexRoute 88 158 } 89 159 export interface FileRoutesById { 90 160 __root__: typeof rootRouteImport 91 161 '/': typeof IndexRoute 162 + '/cabinet': typeof CabinetRouteRouteWithChildren 92 163 '/devices': typeof DevicesRouteRouteWithChildren 93 - '/cabinet': typeof CabinetRoute 164 + '/cabinet/files': typeof CabinetFilesRouteRouteWithChildren 165 + '/cabinet/docs': typeof CabinetDocsRoute 166 + '/cabinet/encrypted': typeof CabinetEncryptedRoute 167 + '/cabinet/settings': typeof CabinetSettingsRoute 168 + '/cabinet/shared': typeof CabinetSharedRoute 169 + '/cabinet/trash': typeof CabinetTrashRoute 94 170 '/devices/cli-callback': typeof DevicesCliCallbackRoute 95 171 '/devices/login': typeof DevicesLoginRoute 96 172 '/devices/oauth-callback': typeof DevicesOauthCallbackRoute 173 + '/cabinet/': typeof CabinetIndexRoute 97 174 '/devices/': typeof DevicesIndexRoute 175 + '/cabinet/files/$': typeof CabinetFilesSplatRoute 98 176 '/devices/pair/accept': typeof DevicesPairAcceptRoute 99 177 '/devices/pair/request': typeof DevicesPairRequestRoute 178 + '/cabinet/files/': typeof CabinetFilesIndexRoute 100 179 } 101 180 export interface FileRouteTypes { 102 181 fileRoutesByFullPath: FileRoutesByFullPath 103 182 fullPaths: 104 183 | '/' 184 + | '/cabinet' 105 185 | '/devices' 106 - | '/cabinet' 186 + | '/cabinet/files' 187 + | '/cabinet/docs' 188 + | '/cabinet/encrypted' 189 + | '/cabinet/settings' 190 + | '/cabinet/shared' 191 + | '/cabinet/trash' 107 192 | '/devices/cli-callback' 108 193 | '/devices/login' 109 194 | '/devices/oauth-callback' 195 + | '/cabinet/' 110 196 | '/devices/' 197 + | '/cabinet/files/$' 111 198 | '/devices/pair/accept' 112 199 | '/devices/pair/request' 200 + | '/cabinet/files/' 113 201 fileRoutesByTo: FileRoutesByTo 114 202 to: 115 203 | '/' 116 - | '/cabinet' 204 + | '/cabinet/docs' 205 + | '/cabinet/encrypted' 206 + | '/cabinet/settings' 207 + | '/cabinet/shared' 208 + | '/cabinet/trash' 117 209 | '/devices/cli-callback' 118 210 | '/devices/login' 119 211 | '/devices/oauth-callback' 212 + | '/cabinet' 120 213 | '/devices' 214 + | '/cabinet/files/$' 121 215 | '/devices/pair/accept' 122 216 | '/devices/pair/request' 217 + | '/cabinet/files' 123 218 id: 124 219 | '__root__' 125 220 | '/' 221 + | '/cabinet' 126 222 | '/devices' 127 - | '/cabinet' 223 + | '/cabinet/files' 224 + | '/cabinet/docs' 225 + | '/cabinet/encrypted' 226 + | '/cabinet/settings' 227 + | '/cabinet/shared' 228 + | '/cabinet/trash' 128 229 | '/devices/cli-callback' 129 230 | '/devices/login' 130 231 | '/devices/oauth-callback' 232 + | '/cabinet/' 131 233 | '/devices/' 234 + | '/cabinet/files/$' 132 235 | '/devices/pair/accept' 133 236 | '/devices/pair/request' 237 + | '/cabinet/files/' 134 238 fileRoutesById: FileRoutesById 135 239 } 136 240 export interface RootRouteChildren { 137 241 IndexRoute: typeof IndexRoute 242 + CabinetRouteRoute: typeof CabinetRouteRouteWithChildren 138 243 DevicesRouteRoute: typeof DevicesRouteRouteWithChildren 139 - CabinetRoute: typeof CabinetRoute 140 244 } 141 245 142 246 declare module '@tanstack/react-router' { 143 247 interface FileRoutesByPath { 144 - '/cabinet': { 145 - id: '/cabinet' 146 - path: '/cabinet' 147 - fullPath: '/cabinet' 148 - preLoaderRoute: typeof CabinetRouteImport 149 - parentRoute: typeof rootRouteImport 150 - } 151 248 '/devices': { 152 249 id: '/devices' 153 250 path: '/devices' ··· 155 252 preLoaderRoute: typeof DevicesRouteRouteImport 156 253 parentRoute: typeof rootRouteImport 157 254 } 255 + '/cabinet': { 256 + id: '/cabinet' 257 + path: '/cabinet' 258 + fullPath: '/cabinet' 259 + preLoaderRoute: typeof CabinetRouteRouteImport 260 + parentRoute: typeof rootRouteImport 261 + } 158 262 '/': { 159 263 id: '/' 160 264 path: '/' ··· 169 273 preLoaderRoute: typeof DevicesIndexRouteImport 170 274 parentRoute: typeof DevicesRouteRoute 171 275 } 276 + '/cabinet/': { 277 + id: '/cabinet/' 278 + path: '/' 279 + fullPath: '/cabinet/' 280 + preLoaderRoute: typeof CabinetIndexRouteImport 281 + parentRoute: typeof CabinetRouteRoute 282 + } 172 283 '/devices/oauth-callback': { 173 284 id: '/devices/oauth-callback' 174 285 path: '/oauth-callback' ··· 190 301 preLoaderRoute: typeof DevicesCliCallbackRouteImport 191 302 parentRoute: typeof DevicesRouteRoute 192 303 } 304 + '/cabinet/trash': { 305 + id: '/cabinet/trash' 306 + path: '/trash' 307 + fullPath: '/cabinet/trash' 308 + preLoaderRoute: typeof CabinetTrashRouteImport 309 + parentRoute: typeof CabinetRouteRoute 310 + } 311 + '/cabinet/shared': { 312 + id: '/cabinet/shared' 313 + path: '/shared' 314 + fullPath: '/cabinet/shared' 315 + preLoaderRoute: typeof CabinetSharedRouteImport 316 + parentRoute: typeof CabinetRouteRoute 317 + } 318 + '/cabinet/settings': { 319 + id: '/cabinet/settings' 320 + path: '/settings' 321 + fullPath: '/cabinet/settings' 322 + preLoaderRoute: typeof CabinetSettingsRouteImport 323 + parentRoute: typeof CabinetRouteRoute 324 + } 325 + '/cabinet/encrypted': { 326 + id: '/cabinet/encrypted' 327 + path: '/encrypted' 328 + fullPath: '/cabinet/encrypted' 329 + preLoaderRoute: typeof CabinetEncryptedRouteImport 330 + parentRoute: typeof CabinetRouteRoute 331 + } 332 + '/cabinet/docs': { 333 + id: '/cabinet/docs' 334 + path: '/docs' 335 + fullPath: '/cabinet/docs' 336 + preLoaderRoute: typeof CabinetDocsRouteImport 337 + parentRoute: typeof CabinetRouteRoute 338 + } 339 + '/cabinet/files': { 340 + id: '/cabinet/files' 341 + path: '/files' 342 + fullPath: '/cabinet/files' 343 + preLoaderRoute: typeof CabinetFilesRouteRouteImport 344 + parentRoute: typeof CabinetRouteRoute 345 + } 346 + '/cabinet/files/': { 347 + id: '/cabinet/files/' 348 + path: '/' 349 + fullPath: '/cabinet/files/' 350 + preLoaderRoute: typeof CabinetFilesIndexRouteImport 351 + parentRoute: typeof CabinetFilesRouteRoute 352 + } 193 353 '/devices/pair/request': { 194 354 id: '/devices/pair/request' 195 355 path: '/pair/request' ··· 204 364 preLoaderRoute: typeof DevicesPairAcceptRouteImport 205 365 parentRoute: typeof DevicesRouteRoute 206 366 } 367 + '/cabinet/files/$': { 368 + id: '/cabinet/files/$' 369 + path: '/$' 370 + fullPath: '/cabinet/files/$' 371 + preLoaderRoute: typeof CabinetFilesSplatRouteImport 372 + parentRoute: typeof CabinetFilesRouteRoute 373 + } 207 374 } 208 375 } 209 376 377 + interface CabinetFilesRouteRouteChildren { 378 + CabinetFilesSplatRoute: typeof CabinetFilesSplatRoute 379 + CabinetFilesIndexRoute: typeof CabinetFilesIndexRoute 380 + } 381 + 382 + const CabinetFilesRouteRouteChildren: CabinetFilesRouteRouteChildren = { 383 + CabinetFilesSplatRoute: CabinetFilesSplatRoute, 384 + CabinetFilesIndexRoute: CabinetFilesIndexRoute, 385 + } 386 + 387 + const CabinetFilesRouteRouteWithChildren = 388 + CabinetFilesRouteRoute._addFileChildren(CabinetFilesRouteRouteChildren) 389 + 390 + interface CabinetRouteRouteChildren { 391 + CabinetFilesRouteRoute: typeof CabinetFilesRouteRouteWithChildren 392 + CabinetDocsRoute: typeof CabinetDocsRoute 393 + CabinetEncryptedRoute: typeof CabinetEncryptedRoute 394 + CabinetSettingsRoute: typeof CabinetSettingsRoute 395 + CabinetSharedRoute: typeof CabinetSharedRoute 396 + CabinetTrashRoute: typeof CabinetTrashRoute 397 + CabinetIndexRoute: typeof CabinetIndexRoute 398 + } 399 + 400 + const CabinetRouteRouteChildren: CabinetRouteRouteChildren = { 401 + CabinetFilesRouteRoute: CabinetFilesRouteRouteWithChildren, 402 + CabinetDocsRoute: CabinetDocsRoute, 403 + CabinetEncryptedRoute: CabinetEncryptedRoute, 404 + CabinetSettingsRoute: CabinetSettingsRoute, 405 + CabinetSharedRoute: CabinetSharedRoute, 406 + CabinetTrashRoute: CabinetTrashRoute, 407 + CabinetIndexRoute: CabinetIndexRoute, 408 + } 409 + 410 + const CabinetRouteRouteWithChildren = CabinetRouteRoute._addFileChildren( 411 + CabinetRouteRouteChildren, 412 + ) 413 + 210 414 interface DevicesRouteRouteChildren { 211 415 DevicesCliCallbackRoute: typeof DevicesCliCallbackRoute 212 416 DevicesLoginRoute: typeof DevicesLoginRoute ··· 231 435 232 436 const rootRouteChildren: RootRouteChildren = { 233 437 IndexRoute: IndexRoute, 438 + CabinetRouteRoute: CabinetRouteRouteWithChildren, 234 439 DevicesRouteRoute: DevicesRouteRouteWithChildren, 235 - CabinetRoute: CabinetRoute, 236 440 } 237 441 export const routeTree = rootRouteImport 238 442 ._addFileChildren(rootRouteChildren)
+8 -8
web/src/routes/__root.tsx
··· 1 - import { createRootRouteWithContext, Outlet, useRouter } from "@tanstack/react-router" 2 - import type { AuthSnapshot } from "@/stores/auth" 1 + import { createRootRouteWithContext, Outlet, useRouter } from "@tanstack/react-router"; 2 + import type { AuthSnapshot } from "@/stores/auth"; 3 3 4 4 export interface RouterContext { 5 - auth: AuthSnapshot 5 + auth: AuthSnapshot; 6 6 } 7 7 8 8 function RootLayout() { 9 - return <Outlet /> 9 + return <Outlet />; 10 10 } 11 11 12 12 function RootError({ error }: Readonly<{ error: Error }>) { 13 - const router = useRouter() 13 + const router = useRouter(); 14 14 15 15 return ( 16 16 <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> ··· 19 19 <p className="text-text-muted mb-6 text-sm">{error.message}</p> 20 20 <button 21 21 onClick={() => { 22 - void router.invalidate() 22 + void router.invalidate(); 23 23 }} 24 24 className="btn btn-neutral btn-sm" 25 25 > ··· 27 27 </button> 28 28 </div> 29 29 </div> 30 - ) 30 + ); 31 31 } 32 32 33 33 export const Route = createRootRouteWithContext<RouterContext>()({ 34 34 component: RootLayout, 35 35 errorComponent: RootError, 36 - }) 36 + });
-94
web/src/routes/cabinet.tsx
··· 1 - import { useState } from "react" 2 - import { createFileRoute, redirect } from "@tanstack/react-router" 3 - import { Sidebar } from "@/components/cabinet/Sidebar" 4 - import { TopBar } from "@/components/cabinet/TopBar" 5 - import { PanelStack } from "@/components/cabinet/PanelStack" 6 - import type { FileItem, Panel, SectionType } from "@/components/cabinet/types" 7 - 8 - function CabinetPage() { 9 - const [panels, setPanels] = useState<Panel[]>([{ type: "root", title: "The Cabinet" }]) 10 - const [viewMode, setViewMode] = useState<"list" | "grid">("list") 11 - const [searchQuery, setSearchQuery] = useState("") 12 - const [starredIds, setStarredIds] = useState( 13 - new Set(["fi-strategy", "fi-brief", "f-projects", "sh-2", "d-thesis"]), 14 - ) 15 - const [loading] = useState(false) 16 - 17 - const currentPanel = panels[panels.length - 1] 18 - 19 - const openSection = (type: SectionType, title: string) => { 20 - setPanels([{ type, title }]) 21 - } 22 - 23 - const openItem = (item: FileItem) => { 24 - if (item.kind === "folder") { 25 - setPanels((prev) => [ 26 - ...prev, 27 - { 28 - type: "folder", 29 - folderId: item.id, 30 - title: item.name, 31 - itemCount: item.items, 32 - }, 33 - ]) 34 - } 35 - } 36 - 37 - const goToPanel = (index: number) => { 38 - setPanels((prev) => prev.slice(0, index + 1)) 39 - } 40 - 41 - const closePanel = () => { 42 - setPanels((prev) => prev.slice(0, -1)) 43 - } 44 - 45 - // TODO (#3): toggleStar and other callbacks are prop-drilled 4 levels deep 46 - // (cabinet → PanelStack → PanelContent → FileListRow). Extract a 47 - // CabinetContext to provide actions + starredIds via context instead. 48 - const toggleStar = (id: string) => { 49 - setStarredIds((prev) => 50 - prev.has(id) ? new Set([...prev].filter((x) => x !== id)) : new Set([...prev, id]), 51 - ) 52 - } 53 - 54 - // TODO (#4): toggleStar, openItem, goToPanel, closePanel are all redefined 55 - // every render — wrap in useCallback so leaf components can be memoized 56 - // with React.memo(). Alternatively, CabinetContext eliminates the issue. 57 - 58 - return ( 59 - <div className="bg-base-300 flex h-screen overflow-hidden font-sans"> 60 - <Sidebar 61 - activePanelType={currentPanel.type} 62 - panelDepth={panels.length} 63 - onOpenSection={openSection} 64 - /> 65 - <main className="flex flex-1 flex-col overflow-hidden"> 66 - <TopBar 67 - searchQuery={searchQuery} 68 - onSearchChange={setSearchQuery} 69 - onOpenSettings={() => openSection("settings", "Settings")} 70 - /> 71 - <PanelStack 72 - panels={panels} 73 - viewMode={viewMode} 74 - starredIds={starredIds} 75 - loading={loading} 76 - onViewModeChange={setViewMode} 77 - onOpenItem={openItem} 78 - onGoToPanel={goToPanel} 79 - onClosePanel={closePanel} 80 - onStar={toggleStar} 81 - /> 82 - </main> 83 - </div> 84 - ) 85 - } 86 - 87 - export const Route = createFileRoute("/cabinet")({ 88 - beforeLoad: ({ context }) => { 89 - if (context.auth.session.status !== "active") { 90 - throw redirect({ to: "/devices/login" }) 91 - } 92 - }, 93 - component: CabinetPage, 94 - })
+89
web/src/routes/cabinet/docs.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { 3 + SparkleIcon, 4 + LockIcon, 5 + ShareNetworkIcon, 6 + GraphIcon, 7 + QuestionIcon, 8 + ArrowSquareOutIcon, 9 + } from "@phosphor-icons/react"; 10 + import { PanelShell } from "@/components/cabinet/PanelShell"; 11 + 12 + const DOCS_SECTIONS = [ 13 + { 14 + id: "getting-started", 15 + title: "Getting Started", 16 + icon: SparkleIcon, 17 + desc: "Set up your cabinet, create your first encrypted file, and explore the interface.", 18 + }, 19 + { 20 + id: "encryption", 21 + title: "Encryption & Keys", 22 + icon: LockIcon, 23 + desc: "How end-to-end encryption works in Opake and how your keys are managed.", 24 + }, 25 + { 26 + id: "sharing", 27 + title: "Sharing & DIDs", 28 + icon: ShareNetworkIcon, 29 + desc: "Share files using decentralised identifiers without a central authority.", 30 + }, 31 + { 32 + id: "at-protocol", 33 + title: "AT Protocol", 34 + icon: GraphIcon, 35 + desc: "The open standard powering Opake — identity, data portability, and federation.", 36 + }, 37 + { 38 + id: "faq", 39 + title: "FAQ", 40 + icon: QuestionIcon, 41 + desc: "Common questions about privacy, security, and how Opake compares to alternatives.", 42 + }, 43 + ]; 44 + 45 + function DocsPage() { 46 + const breadcrumbs = ( 47 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 48 + <ul> 49 + <li> 50 + <span className="text-base-content font-medium">Docs & Help</span> 51 + </li> 52 + </ul> 53 + </div> 54 + ); 55 + 56 + return ( 57 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Documentation · Opake"> 58 + <div className="p-5"> 59 + <div className="mb-5"> 60 + <div className="text-ui text-base-content mb-1 font-medium">Documentation</div> 61 + <div className="text-text-muted text-xs"> 62 + Everything you need to get the most out of Opake. 63 + </div> 64 + </div> 65 + <div className="flex flex-col gap-2"> 66 + {DOCS_SECTIONS.map((s) => ( 67 + <div 68 + key={s.id} 69 + className="card card-bordered border-base-300/50 bg-base-100 cursor-pointer p-3.5" 70 + > 71 + <div className="bg-accent flex size-8 shrink-0 items-center justify-center rounded-lg"> 72 + <s.icon size={14} className="text-primary" /> 73 + </div> 74 + <div className="flex-1"> 75 + <div className="text-ui text-base-content mb-0.5 font-medium">{s.title}</div> 76 + <div className="text-caption text-text-muted leading-relaxed">{s.desc}</div> 77 + </div> 78 + <ArrowSquareOutIcon size={12} className="text-text-faint mt-0.5 shrink-0" /> 79 + </div> 80 + ))} 81 + </div> 82 + </div> 83 + </PanelShell> 84 + ); 85 + } 86 + 87 + export const Route = createFileRoute("/cabinet/docs")({ 88 + component: DocsPage, 89 + });
+47
web/src/routes/cabinet/encrypted.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { LockIcon } from "@phosphor-icons/react"; 3 + import { PanelShell } from "@/components/cabinet/PanelShell"; 4 + 5 + function EncryptedPage() { 6 + const breadcrumbs = ( 7 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 8 + <ul> 9 + <li> 10 + <span className="text-base-content font-medium">Encrypted</span> 11 + </li> 12 + </ul> 13 + </div> 14 + ); 15 + 16 + return ( 17 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Private items"> 18 + <div 19 + role="alert" 20 + className="alert border-border-accent bg-accent mx-4 mt-4 gap-2.5 rounded-xl p-3" 21 + > 22 + <LockIcon size={13} className="text-primary mt-0.5 shrink-0" /> 23 + <div> 24 + <div className="text-accent-content mb-0.5 text-xs font-medium"> 25 + Private encrypted files 26 + </div> 27 + <div className="text-caption text-primary leading-relaxed"> 28 + Only you can decrypt these files. Not shared with anyone. 29 + </div> 30 + </div> 31 + </div> 32 + 33 + <div className="hero py-16"> 34 + <div className="hero-content flex-col text-center"> 35 + <div className="bg-accent flex size-13 items-center justify-center rounded-[14px]"> 36 + <LockIcon size={22} className="text-primary" /> 37 + </div> 38 + <div className="text-ui text-text-muted">No private files yet</div> 39 + </div> 40 + </div> 41 + </PanelShell> 42 + ); 43 + } 44 + 45 + export const Route = createFileRoute("/cabinet/encrypted")({ 46 + component: EncryptedPage, 47 + });
+43
web/src/routes/cabinet/files/$.tsx
··· 1 + import { useEffect } from "react"; 2 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 + import { useShallow } from "zustand/react/shallow"; 4 + import { PanelContent } from "@/components/cabinet/PanelContent"; 5 + import { useDocumentsStore } from "@/stores/documents"; 6 + import { useAuthStore } from "@/stores/auth"; 7 + import { directoryUri, rkeyFromUri } from "@/lib/atUri"; 8 + import type { FileItem } from "@/components/cabinet/types"; 9 + 10 + function SubdirectoryContent() { 11 + const navigate = useNavigate(); 12 + const { _splat: splat = "" } = Route.useParams(); 13 + const segments = splat.split("/").filter(Boolean); 14 + const currentRkey = segments[segments.length - 1]; 15 + 16 + const session = useAuthStore((s) => s.session); 17 + const did = session.status === "active" ? session.did : null; 18 + const currentDirectoryUri = did && currentRkey ? directoryUri(did, currentRkey) : null; 19 + 20 + const ensureDirectoryDecrypted = useDocumentsStore((s) => s.ensureDirectoryDecrypted); 21 + const viewMode = useDocumentsStore((s) => s.viewMode); 22 + const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(currentDirectoryUri))); 23 + 24 + useEffect(() => { 25 + if (currentDirectoryUri) { 26 + void ensureDirectoryDecrypted(currentDirectoryUri); 27 + } 28 + }, [currentDirectoryUri, ensureDirectoryDecrypted]); 29 + 30 + const handleOpen = (item: FileItem) => { 31 + const childRkey = rkeyFromUri(item.uri); 32 + void navigate({ 33 + to: "/cabinet/files/$", 34 + params: { _splat: `${splat}/${childRkey}` }, 35 + }); 36 + }; 37 + 38 + return <PanelContent items={items} viewMode={viewMode} onOpen={handleOpen} />; 39 + } 40 + 41 + export const Route = createFileRoute("/cabinet/files/$")({ 42 + component: SubdirectoryContent, 43 + });
+31
web/src/routes/cabinet/files/index.tsx
··· 1 + import { useEffect } from "react"; 2 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 + import { useShallow } from "zustand/react/shallow"; 4 + import { PanelContent } from "@/components/cabinet/PanelContent"; 5 + import { useDocumentsStore } from "@/stores/documents"; 6 + import { rkeyFromUri } from "@/lib/atUri"; 7 + import type { FileItem } from "@/components/cabinet/types"; 8 + 9 + function RootDirectoryContent() { 10 + const navigate = useNavigate(); 11 + const ensureDirectoryDecrypted = useDocumentsStore((s) => s.ensureDirectoryDecrypted); 12 + const viewMode = useDocumentsStore((s) => s.viewMode); 13 + const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(null))); 14 + 15 + useEffect(() => { 16 + void ensureDirectoryDecrypted(null); 17 + }, [ensureDirectoryDecrypted]); 18 + 19 + const handleOpen = (item: FileItem) => { 20 + void navigate({ 21 + to: "/cabinet/files/$", 22 + params: { _splat: rkeyFromUri(item.uri) }, 23 + }); 24 + }; 25 + 26 + return <PanelContent items={items} viewMode={viewMode} onOpen={handleOpen} />; 27 + } 28 + 29 + export const Route = createFileRoute("/cabinet/files/")({ 30 + component: RootDirectoryContent, 31 + });
+175
web/src/routes/cabinet/files/route.tsx
··· 1 + import { createFileRoute, Link, Outlet, useMatch, useNavigate } from "@tanstack/react-router"; 2 + import { 3 + ListBulletsIcon, 4 + SquaresFourIcon, 5 + PlusIcon, 6 + XIcon, 7 + UploadSimpleIcon, 8 + FolderIcon, 9 + FileTextIcon, 10 + BookOpenIcon, 11 + } from "@phosphor-icons/react"; 12 + import { DropdownMenu } from "@/components/DropdownMenu"; 13 + import { SegmentedToggle } from "@/components/SegmentedToggle"; 14 + import { 15 + Breadcrumbs, 16 + BreadcrumbActive, 17 + BreadcrumbSkeleton, 18 + } from "@/components/cabinet/Breadcrumbs"; 19 + import { PanelShell } from "@/components/cabinet/PanelShell"; 20 + import { PanelSkeleton } from "@/components/cabinet/PanelSkeleton"; 21 + import { TagFilterBar } from "@/components/cabinet/TagFilterBar"; 22 + import { useDocumentsStore } from "@/stores/documents"; 23 + import { useAuthStore } from "@/stores/auth"; 24 + import { directoryUri } from "@/lib/atUri"; 25 + 26 + function FileBrowserLayout() { 27 + const navigate = useNavigate(); 28 + 29 + // Determine current directory from child splat route params 30 + const splatMatch = useMatch({ 31 + from: "/cabinet/files/$", 32 + shouldThrow: false, 33 + }); 34 + const splat = splatMatch?.params._splat; 35 + const segments = splat ? splat.split("/").filter(Boolean) : []; 36 + const rkey = segments.length > 0 ? segments[segments.length - 1] : undefined; 37 + 38 + const session = useAuthStore((s) => s.session); 39 + const did = session.status === "active" ? session.did : null; 40 + const currentDirectoryUri = rkey && did ? directoryUri(did, rkey) : null; 41 + 42 + const loading = useDocumentsStore((s) => s.loading); 43 + const viewMode = useDocumentsStore((s) => s.viewMode); 44 + const setViewMode = useDocumentsStore((s) => s.setViewMode); 45 + const ancestorsOf = useDocumentsStore((s) => s.ancestorsOf); 46 + const items = useDocumentsStore((s) => s.items); 47 + const treeSnapshot = useDocumentsStore((s) => s.treeSnapshot); 48 + const availableTags = useDocumentsStore((s) => s.availableTags); 49 + const activeTagFilters = useDocumentsStore((s) => s.activeTagFilters); 50 + const setTagFilters = useDocumentsStore((s) => s.setTagFilters); 51 + 52 + // Derived values — computed during render, not inside selectors 53 + const ancestors = ancestorsOf(currentDirectoryUri); 54 + 55 + const currentDirectoryItem = currentDirectoryUri ? items[currentDirectoryUri] : undefined; 56 + const currentDirectoryName = currentDirectoryItem?.name ?? null; 57 + 58 + const depth = segments.length > 0 ? segments.length + 1 : 1; 59 + 60 + const footerText = (() => { 61 + const targetUri = currentDirectoryUri ?? treeSnapshot?.rootUri; 62 + if (!targetUri || !treeSnapshot) return "Loading\u2026"; 63 + const dirEntry = treeSnapshot.directories[targetUri]; 64 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard 65 + if (!dirEntry) return "Encrypted"; 66 + return rkey 67 + ? `${dirEntry.entries.length} items \u00b7 Encrypted` 68 + : `${dirEntry.entries.length} items \u00b7 All encrypted \u00b7 AT Protocol`; 69 + })(); 70 + 71 + const handleToggleTag = (tag: string) => { 72 + const current = [...activeTagFilters]; 73 + const index = current.indexOf(tag); 74 + if (index >= 0) { 75 + current.splice(index, 1); 76 + } else { 77 + current.push(tag); 78 + } 79 + setTagFilters(current); 80 + }; 81 + 82 + const handleClose = () => { 83 + if (segments.length > 1) { 84 + void navigate({ 85 + to: "/cabinet/files/$", 86 + params: { _splat: segments.slice(0, -1).join("/") }, 87 + }); 88 + } else { 89 + void navigate({ to: "/cabinet/files" }); 90 + } 91 + }; 92 + 93 + // -- Toolbar -- 94 + const toolbar = ( 95 + <> 96 + <SegmentedToggle 97 + options={[ 98 + { value: "list" as const, icon: ListBulletsIcon }, 99 + { value: "grid" as const, icon: SquaresFourIcon }, 100 + ]} 101 + value={viewMode} 102 + onChange={setViewMode} 103 + /> 104 + 105 + <DropdownMenu 106 + trigger={ 107 + <> 108 + <PlusIcon size={13} /> 109 + New 110 + </> 111 + } 112 + items={[ 113 + { icon: UploadSimpleIcon, label: "Upload file" }, 114 + { icon: FolderIcon, label: "New folder" }, 115 + { icon: FileTextIcon, label: "New document" }, 116 + { icon: BookOpenIcon, label: "New note" }, 117 + ]} 118 + /> 119 + 120 + {depth > 1 && ( 121 + <button onClick={handleClose} className="btn btn-ghost btn-sm btn-square rounded-md"> 122 + <XIcon size={14} className="text-text-muted" /> 123 + </button> 124 + )} 125 + </> 126 + ); 127 + 128 + return ( 129 + <PanelShell 130 + depth={depth} 131 + breadcrumbs={ 132 + <Breadcrumbs> 133 + {rkey ? ( 134 + <li> 135 + <Link to="/cabinet/files" className="text-text-faint"> 136 + The Cabinet 137 + </Link> 138 + </li> 139 + ) : ( 140 + <BreadcrumbActive>The Cabinet</BreadcrumbActive> 141 + )} 142 + {ancestors.map((ancestor, index) => ( 143 + <li key={ancestor.uri}> 144 + <Link 145 + to="/cabinet/files/$" 146 + params={{ _splat: segments.slice(0, index + 1).join("/") }} 147 + className="text-text-faint" 148 + > 149 + {ancestor.name} 150 + </Link> 151 + </li> 152 + ))} 153 + {rkey && currentDirectoryName && ( 154 + <BreadcrumbActive>{currentDirectoryName}</BreadcrumbActive> 155 + )} 156 + {rkey && !currentDirectoryName && <BreadcrumbSkeleton />} 157 + </Breadcrumbs> 158 + } 159 + toolbar={toolbar} 160 + footer={footerText} 161 + > 162 + <TagFilterBar 163 + availableTags={availableTags} 164 + activeFilters={activeTagFilters} 165 + onToggle={handleToggleTag} 166 + onClear={() => setTagFilters([])} 167 + /> 168 + {loading ? <PanelSkeleton /> : <Outlet />} 169 + </PanelShell> 170 + ); 171 + } 172 + 173 + export const Route = createFileRoute("/cabinet/files")({ 174 + component: FileBrowserLayout, 175 + });
+7
web/src/routes/cabinet/index.tsx
··· 1 + import { createFileRoute, redirect } from "@tanstack/react-router"; 2 + 3 + export const Route = createFileRoute("/cabinet/")({ 4 + beforeLoad: () => { 5 + throw redirect({ to: "/cabinet/files" }); 6 + }, 7 + });
+33
web/src/routes/cabinet/route.tsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { createFileRoute, redirect, Outlet } from "@tanstack/react-router"; 3 + import { Sidebar } from "@/components/cabinet/Sidebar"; 4 + import { TopBar } from "@/components/cabinet/TopBar"; 5 + import { useDocumentsStore } from "@/stores/documents"; 6 + 7 + function CabinetLayout() { 8 + const [searchQuery, setSearchQuery] = useState(""); 9 + const fetchAll = useDocumentsStore((s) => s.fetchAll); 10 + 11 + useEffect(() => { 12 + void fetchAll(); 13 + }, [fetchAll]); 14 + 15 + return ( 16 + <div className="bg-base-300 flex h-screen overflow-hidden font-sans"> 17 + <Sidebar /> 18 + <main className="flex flex-1 flex-col overflow-hidden"> 19 + <TopBar searchQuery={searchQuery} onSearchChange={setSearchQuery} /> 20 + <Outlet /> 21 + </main> 22 + </div> 23 + ); 24 + } 25 + 26 + export const Route = createFileRoute("/cabinet")({ 27 + beforeLoad: ({ context }) => { 28 + if (context.auth.session.status !== "active") { 29 + throw redirect({ to: "/devices/login" }); 30 + } 31 + }, 32 + component: CabinetLayout, 33 + });
+67
web/src/routes/cabinet/settings.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { 3 + UserIcon, 4 + LockIcon, 5 + ShareNetworkIcon, 6 + ShieldCheckIcon, 7 + BellIcon, 8 + CaretRightIcon, 9 + } from "@phosphor-icons/react"; 10 + import { PanelShell } from "@/components/cabinet/PanelShell"; 11 + 12 + const SETTINGS_SECTIONS = [ 13 + { label: "Account & Identity", desc: "DID: did:plc:7f2ab3c4d\u20268e91f0", icon: UserIcon }, 14 + { label: "Encryption Keys", desc: "Last rotated 14 days ago \u00b7 Active", icon: LockIcon }, 15 + { 16 + label: "Sharing & Permissions", 17 + desc: "3 active collaborators", 18 + icon: ShareNetworkIcon, 19 + }, 20 + { label: "Connected Devices", desc: "2 devices linked", icon: ShieldCheckIcon }, 21 + { label: "Notifications", desc: "Email & in-app alerts", icon: BellIcon }, 22 + ]; 23 + 24 + function SettingsPage() { 25 + const breadcrumbs = ( 26 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 27 + <ul> 28 + <li> 29 + <span className="text-base-content font-medium">Settings</span> 30 + </li> 31 + </ul> 32 + </div> 33 + ); 34 + 35 + return ( 36 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Account settings"> 37 + <div className="p-5"> 38 + <div className="mb-5"> 39 + <div className="text-ui text-base-content mb-1 font-medium">Settings</div> 40 + <div className="text-text-muted text-xs">Manage your account, keys, and preferences.</div> 41 + </div> 42 + <div className="divider mt-0 mb-4" /> 43 + <div className="flex flex-col gap-1.5"> 44 + {SETTINGS_SECTIONS.map(({ label, desc, icon: Icon }) => ( 45 + <div 46 + key={label} 47 + className="card card-bordered border-base-300/50 bg-base-100 cursor-pointer p-3.5" 48 + > 49 + <div className="bg-bg-stone flex size-8 shrink-0 items-center justify-center rounded-lg"> 50 + <Icon size={14} className="text-text-muted" /> 51 + </div> 52 + <div className="flex-1"> 53 + <div className="text-ui text-base-content font-medium">{label}</div> 54 + <div className="text-caption text-text-muted">{desc}</div> 55 + </div> 56 + <CaretRightIcon size={13} className="text-text-faint" /> 57 + </div> 58 + ))} 59 + </div> 60 + </div> 61 + </PanelShell> 62 + ); 63 + } 64 + 65 + export const Route = createFileRoute("/cabinet/settings")({ 66 + component: SettingsPage, 67 + });
+48
web/src/routes/cabinet/shared.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { UsersIcon } from "@phosphor-icons/react"; 3 + import { PanelShell } from "@/components/cabinet/PanelShell"; 4 + 5 + function SharedPage() { 6 + const breadcrumbs = ( 7 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 8 + <ul> 9 + <li> 10 + <span className="text-base-content font-medium">Shared with me</span> 11 + </li> 12 + </ul> 13 + </div> 14 + ); 15 + 16 + return ( 17 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Shared items · Encrypted"> 18 + <div 19 + role="alert" 20 + className="alert border-success/30 bg-bg-sage mx-4 mt-4 gap-2.5 rounded-xl p-3" 21 + > 22 + <UsersIcon size={13} className="text-success mt-0.5 shrink-0" /> 23 + <div> 24 + <div className="text-success mb-0.5 text-xs font-medium"> 25 + Shared via decentralised identity 26 + </div> 27 + <div className="text-caption text-success/80 leading-relaxed"> 28 + Files shared via DID. Encrypted in transit and at rest — only invited parties can 29 + decrypt. 30 + </div> 31 + </div> 32 + </div> 33 + 34 + <div className="hero py-16"> 35 + <div className="hero-content flex-col text-center"> 36 + <div className="bg-bg-sage flex size-13 items-center justify-center rounded-[14px]"> 37 + <UsersIcon size={22} className="text-success" /> 38 + </div> 39 + <div className="text-ui text-text-muted">No shared files yet</div> 40 + </div> 41 + </div> 42 + </PanelShell> 43 + ); 44 + } 45 + 46 + export const Route = createFileRoute("/cabinet/shared")({ 47 + component: SharedPage, 48 + });
+35
web/src/routes/cabinet/trash.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { TrashIcon } from "@phosphor-icons/react"; 3 + import { PanelShell } from "@/components/cabinet/PanelShell"; 4 + 5 + function TrashPage() { 6 + const breadcrumbs = ( 7 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 8 + <ul> 9 + <li> 10 + <span className="text-base-content font-medium">Trash</span> 11 + </li> 12 + </ul> 13 + </div> 14 + ); 15 + 16 + return ( 17 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Trash · 30 day retention"> 18 + <div className="hero py-16"> 19 + <div className="hero-content flex-col text-center"> 20 + <div className="bg-bg-stone flex size-13 items-center justify-center rounded-[14px]"> 21 + <TrashIcon size={22} className="text-text-faint" /> 22 + </div> 23 + <div className="text-ui text-text-muted">Trash is empty</div> 24 + <div className="text-text-faint max-w-60 text-xs leading-relaxed"> 25 + Deleted files appear here for 30 days before permanent removal. 26 + </div> 27 + </div> 28 + </div> 29 + </PanelShell> 30 + ); 31 + } 32 + 33 + export const Route = createFileRoute("/cabinet/trash")({ 34 + component: TrashPage, 35 + });
+15 -15
web/src/routes/devices/cli-callback.tsx
··· 1 - import { CheckIcon } from "@phosphor-icons/react" 2 - import { useMemo, useEffect } from "react" 3 - import { createFileRoute } from "@tanstack/react-router" 1 + import { CheckIcon } from "@phosphor-icons/react"; 2 + import { useMemo, useEffect } from "react"; 3 + import { createFileRoute } from "@tanstack/react-router"; 4 4 5 5 type CallbackResult = 6 6 | { state: "success"; errorMessage: "" } 7 - | { state: "error"; errorMessage: string } 7 + | { state: "error"; errorMessage: string }; 8 8 9 9 function CliCallbackPage() { 10 10 const { state, errorMessage } = useMemo<CallbackResult>(() => { 11 - const params = new URLSearchParams(window.location.search) 12 - const error = params.get("error") 11 + const params = new URLSearchParams(window.location.search); 12 + const error = params.get("error"); 13 13 if (error) { 14 - return { state: "error", errorMessage: error } 14 + return { state: "error", errorMessage: error }; 15 15 } 16 - return { state: "success", errorMessage: "" } 17 - }, []) 16 + return { state: "success", errorMessage: "" }; 17 + }, []); 18 18 19 19 useEffect(() => { 20 - window.history.replaceState({}, "", window.location.pathname) 21 - }, []) 20 + window.history.replaceState({}, "", window.location.pathname); 21 + }, []); 22 22 23 23 return ( 24 24 <> ··· 35 35 <div className="text-base-content/50 mt-2 flex flex-col gap-3 text-sm"> 36 36 <p>You can close this tab and return to your terminal.</p> 37 37 <p> 38 - <span className="text-base-content/70 font-medium">Note:</span> This logs you into 39 - the CLI only. The web app requires a separate login. 38 + <span className="text-base-content/70 font-medium">Note:</span> This logs you into the 39 + CLI only. The web app requires a separate login. 40 40 </p> 41 41 </div> 42 42 </div> ··· 68 68 </div> 69 69 )} 70 70 </> 71 - ) 71 + ); 72 72 } 73 73 74 74 export const Route = createFileRoute("/devices/cli-callback")({ 75 75 component: CliCallbackPage, 76 - }) 76 + });
+15 -15
web/src/routes/devices/login.tsx
··· 1 - import { useState } from "react" 2 - import { createFileRoute, redirect } from "@tanstack/react-router" 3 - import { useAuthStore } from "@/stores/auth" 1 + import { useState } from "react"; 2 + import { createFileRoute, redirect } from "@tanstack/react-router"; 3 + import { useAuthStore } from "@/stores/auth"; 4 4 5 5 function LoginPage() { 6 - const session = useAuthStore((s) => s.session) 7 - const startLogin = useAuthStore((s) => s.startLogin) 8 - const [handle, setHandle] = useState("") 9 - const isLoading = session.status === "authenticating" 10 - const errorMessage = session.status === "error" ? session.message : null 6 + const session = useAuthStore((s) => s.session); 7 + const startLogin = useAuthStore((s) => s.startLogin); 8 + const [handle, setHandle] = useState(""); 9 + const isLoading = session.status === "authenticating"; 10 + const errorMessage = session.status === "error" ? session.message : null; 11 11 12 12 const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => { 13 - e.preventDefault() 14 - if (!handle.trim()) return 15 - void startLogin(handle.trim()) 16 - } 13 + e.preventDefault(); 14 + if (!handle.trim()) return; 15 + void startLogin(handle.trim()); 16 + }; 17 17 18 18 return ( 19 19 <form onSubmit={handleSubmit} className="card card-bordered bg-base-100 w-80 p-6"> ··· 42 42 {isLoading ? <span className="loading loading-spinner loading-sm" /> : "Sign in"} 43 43 </button> 44 44 </form> 45 - ) 45 + ); 46 46 } 47 47 48 48 export const Route = createFileRoute("/devices/login")({ 49 49 beforeLoad: ({ context }) => { 50 - if (context.auth.session.status === "active") throw redirect({ to: "/devices" }) 50 + if (context.auth.session.status === "active") throw redirect({ to: "/devices" }); 51 51 }, 52 52 component: LoginPage, 53 - }) 53 + });
+27 -24
web/src/routes/devices/oauth-callback.tsx
··· 1 - import { useEffect, useRef } from "react" 2 - import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" 3 - import { useAuthStore } from "@/stores/auth" 1 + import { useEffect, useRef } from "react"; 2 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 + import { useAuthStore } from "@/stores/auth"; 4 4 5 5 function OAuthCallbackPage() { 6 - const navigate = useNavigate() 7 - const session = useAuthStore((s) => s.session) 8 - const completeLogin = useAuthStore((s) => s.completeLogin) 9 - const errorMessage = session.status === "error" ? session.message : null 6 + const navigate = useNavigate(); 7 + const session = useAuthStore((s) => s.session); 8 + const completeLogin = useAuthStore((s) => s.completeLogin); 9 + const errorMessage = session.status === "error" ? session.message : null; 10 10 11 - const hasStartedRef = useRef(false) 11 + const hasStartedRef = useRef(false); 12 12 13 13 useEffect(() => { 14 - if (hasStartedRef.current) return 15 - hasStartedRef.current = true 14 + if (hasStartedRef.current) return; 15 + hasStartedRef.current = true; 16 16 17 - const params = new URLSearchParams(window.location.search) 18 - const code = params.get("code") 19 - const state = params.get("state") 17 + const params = new URLSearchParams(window.location.search); 18 + const code = params.get("code"); 19 + const state = params.get("state"); 20 20 21 - window.history.replaceState({}, "", window.location.pathname) 21 + window.history.replaceState({}, "", window.location.pathname); 22 22 23 23 if (!code || !state) { 24 24 useAuthStore.setState((draft) => { 25 - draft.session = { status: "error", message: "Missing authorization code or state parameter." } 26 - }) 27 - return 25 + draft.session = { 26 + status: "error", 27 + message: "Missing authorization code or state parameter.", 28 + }; 29 + }); 30 + return; 28 31 } 29 32 30 - void completeLogin(code, state) 31 - }, [completeLogin]) 33 + void completeLogin(code, state); 34 + }, [completeLogin]); 32 35 33 36 useEffect(() => { 34 37 if (session.status === "active") { 35 - void navigate({ to: "/devices" }) 38 + void navigate({ to: "/devices" }); 36 39 } 37 - }, [session.status, navigate]) 40 + }, [session.status, navigate]); 38 41 39 42 return ( 40 43 <> ··· 71 74 </div> 72 75 )} 73 76 </> 74 - ) 77 + ); 75 78 } 76 79 77 80 export const Route = createFileRoute("/devices/oauth-callback")({ 78 81 beforeLoad: ({ context }) => { 79 - if (context.auth.session.status === "active") throw redirect({ to: "/devices" }) 82 + if (context.auth.session.status === "active") throw redirect({ to: "/devices" }); 80 83 }, 81 84 component: OAuthCallbackPage, 82 - }) 85 + });
+3 -2
web/src/routes/devices/pair.accept.tsx
··· 2 2 import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 3 import { useAuthStore } from "@/stores/auth"; 4 4 import { getCryptoWorker } from "@/lib/worker"; 5 - import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 5 + import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 6 6 import { listPairRequests, approvePairRequest, type PendingPairRequest } from "@/lib/pairing"; 7 7 import { CheckCircleIcon, WarningIcon } from "@phosphor-icons/react"; 8 8 import { useAppStore } from "@/stores/app"; ··· 72 72 .finally(() => removeLoading("pair-accept-fetch")); 73 73 }, [addLoading, removeLoading]); 74 74 75 - const shouldPoll = state.step === "loading" || state.step === "empty" || state.step === "selecting"; 75 + const shouldPoll = 76 + state.step === "loading" || state.step === "empty" || state.step === "selecting"; 76 77 77 78 useEffect(() => { 78 79 if (!shouldPoll) return;
+1 -1
web/src/routes/devices/pair.request.tsx
··· 2 2 import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 3 import { useAuthStore } from "@/stores/auth"; 4 4 import { getCryptoWorker } from "@/lib/worker"; 5 - import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 5 + import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 6 6 import { formatFingerprint, rkeyFromUri } from "@/lib/encoding"; 7 7 import { 8 8 createPairRequest,
+5 -5
web/src/routes/index.tsx
··· 1 - import { createFileRoute, Link } from "@tanstack/react-router" 2 - import { ArrowRightIcon } from "@phosphor-icons/react" 3 - import { OpakeLogo } from "@/components/OpakeLogo" 1 + import { createFileRoute, Link } from "@tanstack/react-router"; 2 + import { ArrowRightIcon } from "@phosphor-icons/react"; 3 + import { OpakeLogo } from "@/components/OpakeLogo"; 4 4 5 5 function LandingPage() { 6 6 return ( ··· 50 50 </div> 51 51 </section> 52 52 </div> 53 - ) 53 + ); 54 54 } 55 55 56 56 export const Route = createFileRoute("/")({ 57 57 component: LandingPage, 58 - }) 58 + });
+3 -3
web/src/stores/auth.ts
··· 9 9 10 10 import { create } from "zustand"; 11 11 import { immer } from "zustand/middleware/immer"; 12 - import type { OAuthSession, Config } from "@/lib/storage-types"; 13 - import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 12 + import type { OAuthSession, Config } from "@/lib/storageTypes"; 13 + import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 14 14 import { getCryptoWorker } from "@/lib/worker"; 15 15 import { authenticatedXrpc } from "@/lib/api"; 16 16 import { useAppStore } from "@/stores/app"; ··· 132 132 133 133 const did = config.defaultDid; 134 134 const account = config.accounts[did] as 135 - | import("@/lib/storage-types").AccountConfig 135 + | import("@/lib/storageTypes").AccountConfig 136 136 | undefined; 137 137 if (!account) { 138 138 set((draft) => {
+87
web/src/stores/documents/decrypt.ts
··· 1 + // Document metadata decryption — unwrap content key, decrypt envelope, 2 + // update store items via the set callback. 3 + 4 + import { getCryptoWorker } from "@/lib/worker"; 5 + import { base64ToUint8Array } from "@/lib/encoding"; 6 + import { mimeTypeToFileType, formatFileSize } from "@/lib/format"; 7 + import type { FileItem } from "@/components/cabinet/types"; 8 + import type { 9 + PdsRecord, 10 + DocumentRecord, 11 + DocumentMetadata, 12 + EncryptedMetadataEnvelope, 13 + Encryption, 14 + } from "@/lib/pdsTypes"; 15 + 16 + // Minimal draft shape — avoids coupling to the full DocumentsState type. 17 + interface ItemsDraft { 18 + items: Record<string, FileItem>; 19 + } 20 + 21 + export type SetFn = (fn: (draft: ItemsDraft) => void) => void; 22 + 23 + function decryptEnvelope(envelope: EncryptedMetadataEnvelope): { 24 + readonly ciphertext: Uint8Array; 25 + readonly nonce: Uint8Array; 26 + } { 27 + return { 28 + ciphertext: base64ToUint8Array(envelope.ciphertext.$bytes), 29 + nonce: base64ToUint8Array(envelope.nonce.$bytes), 30 + }; 31 + } 32 + 33 + async function unwrapDirectContentKey( 34 + encryption: Encryption & { readonly $type: "app.opake.document#directEncryption" }, 35 + did: string, 36 + privateKey: Uint8Array, 37 + ): Promise<Uint8Array> { 38 + const worker = getCryptoWorker(); 39 + const ourWrappedKey = encryption.envelope.keys.find((wk) => wk.did === did); 40 + if (!ourWrappedKey) throw new Error("No wrapped key for our DID"); 41 + return worker.unwrapKey(ourWrappedKey, privateKey); 42 + } 43 + 44 + export async function decryptDocumentRecord( 45 + record: PdsRecord<DocumentRecord>, 46 + did: string, 47 + privateKey: Uint8Array, 48 + set: SetFn, 49 + ): Promise<string[]> { 50 + const encryption = record.value.encryption; 51 + if (encryption.$type !== "app.opake.document#directEncryption") { 52 + set((draft) => { 53 + draft.items[record.uri] = { 54 + ...draft.items[record.uri], 55 + name: "[Keyring encrypted]", 56 + decrypted: true, 57 + }; 58 + }); 59 + return []; 60 + } 61 + 62 + const contentKey = await unwrapDirectContentKey(encryption, did, privateKey); 63 + const { ciphertext, nonce } = decryptEnvelope(record.value.encryptedMetadata); 64 + const worker = getCryptoWorker(); 65 + const metadata: DocumentMetadata = await worker.decryptMetadata(contentKey, ciphertext, nonce); 66 + 67 + set((draft) => { 68 + draft.items[record.uri] = { 69 + ...draft.items[record.uri], 70 + name: metadata.name, 71 + fileType: metadata.mimeType ? mimeTypeToFileType(metadata.mimeType) : undefined, 72 + size: metadata.size != null ? formatFileSize(metadata.size) : undefined, 73 + mimeType: metadata.mimeType ?? undefined, 74 + tags: metadata.tags ?? [], 75 + description: metadata.description ?? undefined, 76 + decrypted: true, 77 + }; 78 + }); 79 + 80 + return metadata.tags ?? []; 81 + } 82 + 83 + export function markDecryptionFailed(uri: string, set: SetFn): void { 84 + set((draft) => { 85 + draft.items[uri] = { ...draft.items[uri], name: "[Unable to decrypt]", decrypted: true }; 86 + }); 87 + }
+32
web/src/stores/documents/fetch.ts
··· 1 + // Paginated PDS record fetching via authenticated XRPC. 2 + 3 + import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 4 + import { authenticatedXrpc } from "@/lib/api"; 5 + import type { ListRecordsResponse, PdsRecord } from "@/lib/pdsTypes"; 6 + import type { Session } from "@/lib/storageTypes"; 7 + 8 + export const storage = new IndexedDbStorage(); 9 + 10 + export async function fetchAllRecords<T>( 11 + pdsUrl: string, 12 + did: string, 13 + collection: string, 14 + session: Session, 15 + cursor?: string, 16 + ): Promise<readonly PdsRecord<T>[]> { 17 + const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""; 18 + const response = (await authenticatedXrpc( 19 + { 20 + pdsUrl, 21 + lexicon: `com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=100${cursorParam}`, 22 + }, 23 + session, 24 + )) as ListRecordsResponse<T>; 25 + 26 + if (!response.cursor || response.records.length === 0) { 27 + return response.records; 28 + } 29 + 30 + const rest = await fetchAllRecords<T>(pdsUrl, did, collection, session, response.cursor); 31 + return [...response.records, ...rest]; 32 + }
+49
web/src/stores/documents/file-items.ts
··· 1 + // Pure FileItem constructors and tag filtering. 2 + 3 + import { formatRelativeDate } from "@/lib/format"; 4 + import type { FileItem } from "@/components/cabinet/types"; 5 + import type { PdsRecord, DocumentRecord, DirectoryRecord } from "@/lib/pdsTypes"; 6 + 7 + export function directoryItemFromSnapshot( 8 + uri: string, 9 + name: string, 10 + entryCount: number, 11 + record: PdsRecord<DirectoryRecord>, 12 + ): FileItem { 13 + return { 14 + id: uri, 15 + uri, 16 + name, 17 + kind: "folder", 18 + encrypted: true, 19 + status: "private", 20 + items: entryCount, 21 + modified: formatRelativeDate(record.value.modifiedAt ?? record.value.createdAt), 22 + decrypted: true, 23 + tags: [], 24 + }; 25 + } 26 + 27 + export function documentPlaceholder(record: PdsRecord<DocumentRecord>): FileItem { 28 + return { 29 + id: record.uri, 30 + uri: record.uri, 31 + name: "", 32 + kind: "file", 33 + encrypted: true, 34 + status: "private", 35 + modified: formatRelativeDate(record.value.modifiedAt ?? record.value.createdAt), 36 + decrypted: false, 37 + tags: [], 38 + }; 39 + } 40 + 41 + export function applyTagFilter( 42 + items: readonly FileItem[], 43 + activeFilters: readonly string[], 44 + ): FileItem[] { 45 + if (activeFilters.length === 0) return [...items]; 46 + return items.filter( 47 + (item) => item.kind === "folder" || item.tags.some((tag) => activeFilters.includes(tag)), 48 + ); 49 + }
+1
web/src/stores/documents/index.ts
··· 1 + export { useDocumentsStore } from "./store";
+279
web/src/stores/documents/store.ts
··· 1 + // Documents store — directory tree from WASM, lazy per-directory document decryption. 2 + 3 + import { create } from "zustand"; 4 + import { immer } from "zustand/middleware/immer"; 5 + import { castDraft } from "immer"; 6 + import { useAuthStore } from "@/stores/auth"; 7 + import { getCryptoWorker } from "@/lib/worker"; 8 + import { base64ToUint8Array } from "@/lib/encoding"; 9 + import type { FileItem } from "@/components/cabinet/types"; 10 + import type { 11 + PdsRecord, 12 + DocumentRecord, 13 + DirectoryRecord, 14 + DirectoryTreeSnapshot, 15 + } from "@/lib/pdsTypes"; 16 + import { rkeyFromUri } from "@/lib/atUri"; 17 + import { storage, fetchAllRecords } from "./fetch"; 18 + import { decryptDocumentRecord, markDecryptionFailed } from "./decrypt"; 19 + import { directoryItemFromSnapshot, documentPlaceholder, applyTagFilter } from "./file-items"; 20 + 21 + // --------------------------------------------------------------------------- 22 + // State 23 + // --------------------------------------------------------------------------- 24 + 25 + interface DirectoryAncestor { 26 + readonly uri: string; 27 + readonly name: string; 28 + readonly rkey: string; 29 + } 30 + 31 + interface DocumentsState { 32 + items: Record<string, FileItem>; 33 + treeSnapshot: DirectoryTreeSnapshot | null; 34 + documentRecords: Record<string, PdsRecord<DocumentRecord>>; 35 + decryptedDirectories: Set<string>; 36 + loading: boolean; 37 + error: string | null; 38 + activeTagFilters: string[]; 39 + availableTags: string[]; 40 + viewMode: "list" | "grid"; 41 + 42 + readonly fetchAll: () => Promise<void>; 43 + readonly ensureDirectoryDecrypted: (directoryUri: string | null) => Promise<void>; 44 + readonly itemsForDirectory: (directoryUri: string | null) => FileItem[]; 45 + readonly setTagFilters: (tags: string[]) => void; 46 + readonly setViewMode: (mode: "list" | "grid") => void; 47 + readonly ancestorsOf: (directoryUri: string | null) => readonly DirectoryAncestor[]; 48 + } 49 + 50 + // --------------------------------------------------------------------------- 51 + // Store 52 + // --------------------------------------------------------------------------- 53 + 54 + export const useDocumentsStore = create<DocumentsState>()( 55 + immer((set, get) => ({ 56 + items: {}, 57 + treeSnapshot: null, 58 + documentRecords: {}, 59 + decryptedDirectories: new Set<string>(), 60 + loading: false, 61 + error: null, 62 + activeTagFilters: [], 63 + availableTags: [], 64 + viewMode: "list", 65 + 66 + fetchAll: async () => { 67 + const authState = useAuthStore.getState(); 68 + if (authState.session.status !== "active") return; 69 + 70 + const { did, pdsUrl } = authState.session; 71 + 72 + set((draft) => { 73 + draft.loading = true; 74 + draft.error = null; 75 + draft.items = {}; 76 + draft.treeSnapshot = null; 77 + draft.documentRecords = {}; 78 + draft.decryptedDirectories = new Set(); 79 + draft.availableTags = []; 80 + }); 81 + 82 + try { 83 + const session = await storage.loadSession(did); 84 + const identity = await storage.loadIdentity(did); 85 + const privateKey = base64ToUint8Array(identity.private_key); 86 + 87 + const [documentRecords, directoryRecords] = await Promise.all([ 88 + fetchAllRecords<DocumentRecord>(pdsUrl, did, "app.opake.document", session), 89 + fetchAllRecords<DirectoryRecord>(pdsUrl, did, "app.opake.directory", session), 90 + ]); 91 + 92 + // Build directory tree in WASM — decrypts all directory names in one call 93 + const worker = getCryptoWorker(); 94 + const snapshot = await worker.buildDirectoryTree(directoryRecords, did, privateKey); 95 + 96 + // Build a lookup for directory records by URI (for timestamps) 97 + const dirRecordsByUri = Object.fromEntries( 98 + directoryRecords.map((r) => [r.uri, r] as const), 99 + ); 100 + 101 + // Create directory FileItems from the snapshot (names already decrypted) 102 + const directoryItems = Object.entries(snapshot.directories) 103 + .filter(([uri]) => uri in dirRecordsByUri) 104 + .map( 105 + ([uri, entry]) => 106 + [ 107 + uri, 108 + directoryItemFromSnapshot( 109 + uri, 110 + entry.name, 111 + entry.entries.length, 112 + dirRecordsByUri[uri], 113 + ), 114 + ] as const, 115 + ); 116 + 117 + // Create placeholder FileItems for all documents 118 + const documentItems = documentRecords.map((r) => [r.uri, documentPlaceholder(r)] as const); 119 + 120 + const items: Record<string, FileItem> = Object.fromEntries([ 121 + ...directoryItems, 122 + ...documentItems, 123 + ]); 124 + 125 + const docRecordsMap: Record<string, PdsRecord<DocumentRecord>> = Object.fromEntries( 126 + documentRecords.map((r) => [r.uri, r] as const), 127 + ); 128 + 129 + set((draft) => { 130 + draft.items = items; 131 + draft.treeSnapshot = castDraft(snapshot); 132 + draft.documentRecords = castDraft(docRecordsMap); 133 + draft.loading = false; 134 + }); 135 + 136 + // Eagerly decrypt root directory's documents 137 + await get().ensureDirectoryDecrypted(null); 138 + } catch (error) { 139 + console.error("[documents] fetchAll failed:", error); 140 + set((draft) => { 141 + draft.loading = false; 142 + draft.error = error instanceof Error ? error.message : String(error); 143 + }); 144 + } 145 + }, 146 + 147 + ensureDirectoryDecrypted: async (directoryUri: string | null) => { 148 + const state = get(); 149 + const { treeSnapshot, decryptedDirectories, documentRecords } = state; 150 + if (!treeSnapshot) return; 151 + 152 + const targetUri = directoryUri ?? treeSnapshot.rootUri; 153 + if (!targetUri) return; 154 + 155 + if (decryptedDirectories.has(targetUri)) return; 156 + 157 + // Mark immediately to prevent concurrent calls 158 + set((draft) => { 159 + draft.decryptedDirectories.add(targetUri); 160 + }); 161 + 162 + const dirEntry = treeSnapshot.directories[targetUri]; 163 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 164 + if (!dirEntry) return; 165 + 166 + const authState = useAuthStore.getState(); 167 + if (authState.session.status !== "active") return; 168 + 169 + const { did } = authState.session; 170 + const identity = await storage.loadIdentity(did); 171 + const privateKey = base64ToUint8Array(identity.private_key); 172 + 173 + // Filter to document entries (not in snapshot.directories = not a directory) 174 + const documentUris = dirEntry.entries.filter((uri) => !(uri in treeSnapshot.directories)); 175 + 176 + // Decrypt sequentially to avoid overwhelming the worker 177 + const collectedTags = await documentUris.reduce( 178 + async (accPromise, uri) => { 179 + const acc = await accPromise; 180 + const record = documentRecords[uri]; 181 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 182 + if (!record) return acc; 183 + 184 + try { 185 + const tags = await decryptDocumentRecord(record, did, privateKey, set); 186 + return [...acc, ...tags]; 187 + } catch (error) { 188 + console.warn("[documents] failed to decrypt document:", uri, error); 189 + markDecryptionFailed(uri, set); 190 + return acc; 191 + } 192 + }, 193 + Promise.resolve([] as string[]), 194 + ); 195 + 196 + if (collectedTags.length > 0) { 197 + set((draft) => { 198 + const merged = new Set([...draft.availableTags, ...collectedTags]); 199 + draft.availableTags = [...merged].sort((a, b) => a.localeCompare(b)); 200 + }); 201 + } 202 + }, 203 + 204 + itemsForDirectory: (directoryUri: string | null): FileItem[] => { 205 + const state = get(); 206 + const { items, treeSnapshot, activeTagFilters } = state; 207 + 208 + if (!treeSnapshot) { 209 + return applyTagFilter(Object.values(items), activeTagFilters); 210 + } 211 + 212 + const targetUri = directoryUri ?? treeSnapshot.rootUri; 213 + if (!targetUri) { 214 + return applyTagFilter(Object.values(items), activeTagFilters); 215 + } 216 + 217 + const dirEntry = treeSnapshot.directories[targetUri]; 218 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard 219 + if (!dirEntry) return []; 220 + 221 + const ordered = dirEntry.entries 222 + .map((entryUri) => items[entryUri]) 223 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: entry URIs may reference records not yet fetched 224 + .filter((item): item is FileItem => item != null); 225 + 226 + return applyTagFilter(ordered, activeTagFilters); 227 + }, 228 + 229 + setTagFilters: (tags: string[]) => { 230 + set((draft) => { 231 + draft.activeTagFilters = tags; 232 + }); 233 + }, 234 + 235 + setViewMode: (mode: "list" | "grid") => { 236 + set((draft) => { 237 + draft.viewMode = mode; 238 + }); 239 + }, 240 + 241 + ancestorsOf: (directoryUri: string | null): readonly DirectoryAncestor[] => { 242 + const { treeSnapshot } = get(); 243 + if (!treeSnapshot || !directoryUri) return []; 244 + 245 + // Find which directory's entries contain this URI 246 + const findParent = (childUri: string): string | null => 247 + Object.entries(treeSnapshot.directories).find(([, entry]) => 248 + entry.entries.includes(childUri), 249 + )?.[0] ?? null; 250 + 251 + // Collect intermediate ancestors (between root and target, exclusive of both) 252 + // Uses recursive helper to avoid mutable loops 253 + const collectAncestors = ( 254 + current: string, 255 + acc: readonly DirectoryAncestor[], 256 + ): readonly DirectoryAncestor[] => { 257 + const parentUri = findParent(current); 258 + if (!parentUri) return acc; 259 + 260 + // Stop before adding root — root is always rendered as "The Cabinet" 261 + if (parentUri === treeSnapshot.rootUri) return acc; 262 + 263 + const parentEntry = treeSnapshot.directories[parentUri]; 264 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 265 + if (!parentEntry) return acc; 266 + 267 + const ancestor: DirectoryAncestor = { 268 + uri: parentUri, 269 + name: parentEntry.name, 270 + rkey: rkeyFromUri(parentUri), 271 + }; 272 + 273 + return collectAncestors(parentUri, [ancestor, ...acc]); 274 + }; 275 + 276 + return collectAncestors(directoryUri, []); 277 + }, 278 + })), 279 + );
+101 -24
web/src/workers/crypto.worker.ts
··· 1 - import * as Comlink from "comlink" 1 + import * as Comlink from "comlink"; 2 2 import init, { 3 3 bindingCheck, 4 4 generateContentKey, ··· 8 8 unwrapKey, 9 9 wrapContentKeyForKeyring, 10 10 unwrapContentKeyFromKeyring, 11 + decryptMetadata as wasmDecryptMetadata, 12 + decryptDirectoryMetadata as wasmDecryptDirectoryMetadata, 11 13 generateDpopKeyPair as wasmGenerateDpopKeyPair, 12 14 createDpopProof as wasmCreateDpopProof, 13 15 generatePkce as wasmGeneratePkce, 14 16 generateIdentity as wasmGenerateIdentity, 15 17 generateEphemeralKeypair as wasmGenerateEphemeralKeypair, 16 - } from "@/wasm/opake-wasm/opake" 18 + DirectoryTreeHandle, 19 + } from "@/wasm/opake-wasm/opake"; 17 20 import type { 18 21 EncryptedPayload, 19 22 WrappedKey, 20 23 DpopKeyPair, 21 24 PkceChallenge, 22 25 EphemeralKeypair, 23 - } from "@/lib/crypto-types" 24 - import type { Identity } from "@/lib/storage-types" 26 + } from "@/lib/cryptoTypes"; 27 + import type { 28 + DocumentMetadata, 29 + DirectoryMetadata, 30 + DirectoryTreeSnapshot, 31 + PdsRecord, 32 + DirectoryRecord, 33 + } from "@/lib/pdsTypes"; 34 + import type { Identity } from "@/lib/storageTypes"; 25 35 26 - console.debug("[worker] initializing WASM") 27 - await init() 28 - console.debug("[worker] ready, binding check:", bindingCheck()) 36 + console.debug("[worker] initializing WASM"); 37 + await init(); 38 + console.debug("[worker] ready, binding check:", bindingCheck()); 39 + 40 + // --------------------------------------------------------------------------- 41 + // Stateful directory tree held across calls 42 + // --------------------------------------------------------------------------- 43 + 44 + // eslint-disable-next-line functional/no-let -- stateful WASM handle held across calls 45 + let directoryTree: DirectoryTreeHandle | null = null; 29 46 30 47 const cryptoApi = { 31 48 ping(): string { 32 - return "pong" 49 + return "pong"; 33 50 }, 34 51 35 52 bindingCheck(): string { 36 - return bindingCheck() 53 + return bindingCheck(); 37 54 }, 38 55 39 56 generateContentKey(): Uint8Array { 40 - return generateContentKey() 57 + return generateContentKey(); 41 58 }, 42 59 43 60 encryptBlob(key: Uint8Array, plaintext: Uint8Array): EncryptedPayload { 44 - return encryptBlob(key, plaintext) as EncryptedPayload 61 + return encryptBlob(key, plaintext) as EncryptedPayload; 45 62 }, 46 63 47 64 decryptBlob(key: Uint8Array, ciphertext: Uint8Array, nonce: Uint8Array): Uint8Array { 48 - return decryptBlob(key, ciphertext, nonce) 65 + return decryptBlob(key, ciphertext, nonce); 49 66 }, 50 67 51 68 wrapKey(contentKey: Uint8Array, recipientPubKey: Uint8Array, recipientDid: string): WrappedKey { 52 - return wrapKey(contentKey, recipientPubKey, recipientDid) as WrappedKey 69 + return wrapKey(contentKey, recipientPubKey, recipientDid) as WrappedKey; 53 70 }, 54 71 55 72 unwrapKey(wrappedKey: WrappedKey, privateKey: Uint8Array): Uint8Array { 56 - return unwrapKey(wrappedKey, privateKey) 73 + return unwrapKey(wrappedKey, privateKey); 57 74 }, 58 75 59 76 wrapContentKeyForKeyring(contentKey: Uint8Array, groupKey: Uint8Array): Uint8Array { 60 - return wrapContentKeyForKeyring(contentKey, groupKey) 77 + return wrapContentKeyForKeyring(contentKey, groupKey); 61 78 }, 62 79 63 80 unwrapContentKeyFromKeyring(wrapped: Uint8Array, groupKey: Uint8Array): Uint8Array { 64 - return unwrapContentKeyFromKeyring(wrapped, groupKey) 81 + return unwrapContentKeyFromKeyring(wrapped, groupKey); 82 + }, 83 + 84 + // Metadata decryption 85 + 86 + decryptMetadata(key: Uint8Array, ciphertext: Uint8Array, nonce: Uint8Array): DocumentMetadata { 87 + return wasmDecryptMetadata(key, ciphertext, nonce) as DocumentMetadata; 88 + }, 89 + 90 + decryptDirectoryMetadata( 91 + key: Uint8Array, 92 + ciphertext: Uint8Array, 93 + nonce: Uint8Array, 94 + ): DirectoryMetadata { 95 + return wasmDecryptDirectoryMetadata(key, ciphertext, nonce) as DirectoryMetadata; 65 96 }, 66 97 67 98 // OAuth / DPoP 68 99 69 100 generateDpopKeyPair(): DpopKeyPair { 70 - return wasmGenerateDpopKeyPair() as DpopKeyPair 101 + return wasmGenerateDpopKeyPair() as DpopKeyPair; 71 102 }, 72 103 73 104 createDpopProof( ··· 85 116 timestamp, 86 117 nonce ?? undefined, 87 118 accessToken ?? undefined, 88 - ) 119 + ); 89 120 }, 90 121 91 122 generatePkce(): PkceChallenge { 92 - return wasmGeneratePkce() as PkceChallenge 123 + return wasmGeneratePkce() as PkceChallenge; 93 124 }, 94 125 95 126 generateIdentity(did: string): Identity { 96 - return wasmGenerateIdentity(did) as Identity 127 + return wasmGenerateIdentity(did) as Identity; 97 128 }, 98 129 99 130 generateEphemeralKeypair(): EphemeralKeypair { 100 - return wasmGenerateEphemeralKeypair() as EphemeralKeypair 131 + return wasmGenerateEphemeralKeypair() as EphemeralKeypair; 101 132 }, 102 - } 133 + 134 + // --------------------------------------------------------------------------- 135 + // Directory tree (stateful — single instance held in worker) 136 + // --------------------------------------------------------------------------- 137 + 138 + buildDirectoryTree( 139 + records: readonly PdsRecord<DirectoryRecord>[], 140 + did: string, 141 + privateKey: Uint8Array, 142 + ): DirectoryTreeSnapshot { 143 + if (directoryTree) { 144 + directoryTree.free(); 145 + directoryTree = null; 146 + } 103 147 104 - export type CryptoApi = typeof cryptoApi 148 + const input = records.map((r) => ({ uri: r.uri, value: r.value })); 149 + directoryTree = new DirectoryTreeHandle(input, did, privateKey); 150 + return directoryTree.snapshot() as DirectoryTreeSnapshot; 151 + }, 105 152 106 - Comlink.expose(cryptoApi) 153 + treeRootUri(): string | undefined { 154 + return directoryTree?.rootUri(); 155 + }, 156 + 157 + treeEntriesFor(uri: string): readonly string[] | null { 158 + return (directoryTree?.entriesFor(uri) as string[] | null) ?? null; 159 + }, 160 + 161 + treeDirectoryName(uri: string): string | undefined { 162 + return directoryTree?.directoryName(uri); 163 + }, 164 + 165 + treeIsDirectory(uri: string): boolean { 166 + return directoryTree?.isDirectory(uri) ?? false; 167 + }, 168 + 169 + treeFindParent(uri: string): string | undefined { 170 + return directoryTree?.findParent(uri); 171 + }, 172 + 173 + destroyDirectoryTree(): void { 174 + if (directoryTree) { 175 + directoryTree.free(); 176 + directoryTree = null; 177 + } 178 + }, 179 + }; 180 + 181 + export type CryptoApi = typeof cryptoApi; 182 + 183 + Comlink.expose(cryptoApi);
+2 -2
web/tests/lib/indexeddb-storage.test.ts
··· 1 1 import "fake-indexeddb/auto"; 2 2 import { describe, it, expect, beforeEach, afterEach } from "vitest"; 3 - import { IndexedDbStorage } from "../../src/lib/indexeddb-storage"; 3 + import { IndexedDbStorage } from "../../src/lib/indexeddbStorage"; 4 4 import { StorageError } from "../../src/lib/storage"; 5 - import type { Config, Identity, Session } from "../../src/lib/storage-types"; 5 + import type { Config, Identity, Session } from "../../src/lib/storageTypes"; 6 6 7 7 let storage: IndexedDbStorage; 8 8 let dbCounter = 0;