Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bcf-api-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ifc-lite/bcf-api': minor
---

New package: REST client for buildingSMART BCF API (OpenCDE) servers. Typed BCF API 2.1 endpoints (projects, extensions, topics with OData paging, comments, viewpoints, components, snapshots), OAuth2 password, refresh, and client-credentials grants against the server's discovered token endpoint, and `fetchProjectAsBCF` to pull a whole server project into the `@ifc-lite/bcf` in-memory model with per-item warning degradation.
16 changes: 9 additions & 7 deletions apps/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,36 +28,38 @@
"@codemirror/view": "^6.43.8",
"@floating-ui/dom": "^1.8.0",
"@ifc-lite/bcf": "workspace:^",
"@ifc-lite/bcf-api": "workspace:^",
"@ifc-lite/cache": "workspace:^",
"@ifc-lite/collab": "workspace:^",
"@ifc-lite/clash": "workspace:^",
"@ifc-lite/collab": "workspace:^",
"@ifc-lite/create": "workspace:^",
"@ifc-lite/data": "workspace:^",
"@ifc-lite/diff": "workspace:^",
"@ifc-lite/ifcx": "workspace:^",
"@ifc-lite/merge": "workspace:^",
"@ifc-lite/drawing-2d": "workspace:^",
"@ifc-lite/encoding": "workspace:^",
"@ifc-lite/export": "workspace:^",
"@ifc-lite/extensions": "workspace:^",
"@ifc-lite/geometry": "workspace:^",
"@ifc-lite/plugin-api": "workspace:^",
"@ifc-lite/ids": "workspace:^",
"@ifc-lite/ifcx": "workspace:^",
"@ifc-lite/lens": "workspace:^",
"@ifc-lite/lists": "workspace:^",
"@ifc-lite/mcp": "workspace:^",
"@ifc-lite/merge": "workspace:^",
"@ifc-lite/mutations": "workspace:^",
"@ifc-lite/oauth-pkce": "workspace:^",
"@ifc-lite/parser": "workspace:^",
"@ifc-lite/plugin-api": "workspace:^",
"@ifc-lite/pointcloud": "workspace:^",
"@ifc-lite/query": "workspace:^",
"@ifc-lite/source-dalux": "workspace:^",
"@ifc-lite/source-dropbox": "workspace:^",
"@ifc-lite/source-msgraph": "workspace:^",
"@ifc-lite/renderer": "workspace:^",
"@ifc-lite/sandbox": "workspace:^",
"@ifc-lite/sdk": "workspace:^",
"@ifc-lite/server-client": "workspace:^",
"@ifc-lite/solar": "workspace:^",
"@ifc-lite/source-dalux": "workspace:^",
"@ifc-lite/source-dropbox": "workspace:^",
"@ifc-lite/source-msgraph": "workspace:^",
"@ifc-lite/spatial": "workspace:^",
"@ifc-lite/wasm": "workspace:^",
"@radix-ui/react-collapsible": "^1.1.20",
Expand Down
98 changes: 98 additions & 0 deletions apps/viewer/public/oauth/bcf/callback.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>BCF server sign-in</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1a1a1a;
background: #ffffff;
}
@media (prefers-color-scheme: dark) {
body {
color: #e8e8e8;
background: #131313;
}
}
p {
margin: 0;
padding: 24px;
text-align: center;
}
</style>
</head>
<body>
<!--
OAuth redirect target for the BCF server connector (BCF_OAUTH_REDIRECT_PATH
in apps/viewer/src/services/bcf-server.ts). Served from `public/`, so it is a plain static
file: no bundler entry, no module script, nothing that pulls in the app.

That is the entire point of the file. This URL is opened in a 500x700
popup, and the app's SPA fallback (Vite in dev, vercel.json's catch-all
rewrite in prod) used to answer it with index.html, which booted a second
full copy of the viewer, WASM and all, inside the popup just to read a
query string. The dev-server route that maps the extension-less
REDIRECT_PATH here lives in apps/viewer/vite-plugins/oauth-callback.ts;
production does it with a vercel.json rewrite.

The handoff is a BroadcastChannel rather than the popup's opener, because
the app serves Cross-Origin-Opener-Policy: same-origin and the
cross-origin authorization hop severs the opener relationship in both
directions. packages/oauth-pkce/src/callback-channel.ts documents the
probe and the consequences.
-->
<p id="status">Completing sign-in. You can close this window.</p>
<script>
(function () {
'use strict';
// Keep byte-identical to OAUTH_CALLBACK_CHANNEL in
// packages/oauth-pkce/src/callback-channel.ts. This file is not
// bundled, so it cannot import the constant.
var CHANNEL = 'ifc-lite:oauth-callback';

var params = new URLSearchParams(window.location.search);
try {
var channel = new BroadcastChannel(CHANNEL);
channel.postMessage({
type: CHANNEL,
// Routes the message to the sign-in attempt that started it; the
// opener ignores anything carrying a different state, and
// re-validates it before trusting the code.
state: params.get('state') || '',
url: window.location.href,
});
channel.close();
} catch (err) {
document.getElementById('status').textContent =
'Could not hand the sign-in result back to the app: ' +
(err && err.message ? err.message : String(err));
return;
}

// The opener has the result now; this window has no further job.
//
// Verified live in Chrome against the running viewer (COOP
// same-origin, crossOriginIsolated): a popup severed from its opener
// by the cross-origin authorization hop CAN still close itself here.
// The window went away within ~10 ms of landing on this page, twice
// over. What COOP takes away is the OPENER's handle on the popup
// (popup.close() from the other side is inert), not a script-opened
// document's ability to close itself.
//
// If a browser ever refuses, nothing breaks: the opener already has
// its result, and this page is left showing the message above.
window.close();
})();
</script>
</body>
</html>
15 changes: 15 additions & 0 deletions apps/viewer/src/components/viewer/BCFPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Download,
User,
MapPin,
Cloud,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { tourAnchor, TOUR_ANCHORS } from '@/lib/tours/anchors';
Expand All @@ -40,6 +41,7 @@ import { useBCF } from '@/hooks/useBCF';
import { BCFTopicList } from './bcf/BCFTopicList';
import { BCFTopicDetail } from './bcf/BCFTopicDetail';
import { BCFCreateTopicForm } from './bcf/BCFCreateTopicForm';
import { BCFServerDialog } from './bcf/BCFServerDialog';
import { openGenericFileDialog } from '@/services/file-dialog';
import { downloadBlob, sanitizeFilename } from '@/lib/export/download';

Expand Down Expand Up @@ -100,6 +102,7 @@ export function BCFPanel({ onClose }: BCFPanelProps) {
// Editing the active topic's fields in place (reuses the create form). (#1461)
const [showEditForm, setShowEditForm] = useState(false);
const [showAuthorDialog, setShowAuthorDialog] = useState(false);
const [showServerDialog, setShowServerDialog] = useState(false);
const [tempAuthor, setTempAuthor] = useState(bcfAuthor);
// Viewpoint previewed in the create form and attached to the new topic.
const [createViewpoint, setCreateViewpoint] = useState<BCFViewpoint | null>(null);
Expand Down Expand Up @@ -419,6 +422,15 @@ export function BCFPanel({ onClose }: BCFPanelProps) {
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setShowServerDialog(true)}
title="BCF server"
>
<Cloud className="h-4 w-4" />
</Button>
<Button
variant={bcfOverlayVisible ? 'secondary' : 'ghost'}
size="icon"
Expand Down Expand Up @@ -503,6 +515,9 @@ export function BCFPanel({ onClose }: BCFPanelProps) {
/>
)}

{/* BCF server connection */}
<BCFServerDialog open={showServerDialog} onOpenChange={setShowServerDialog} />

{/* Author Dialog */}
{showAuthorDialog && (
<div className="absolute inset-0 bg-background/90 flex items-center justify-center p-4">
Expand Down
Loading
Loading