Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
aa43327
IDP-45: add the favicon
D-GopalKrishna Oct 9, 2026
0ed0e67
IDP-45: dual login (EMBER-DANDI and OSB) from a Login dialog
D-GopalKrishna Oct 9, 2026
d8cf116
IDP-45: My dandisets link; Protocols page's Data upload button
D-GopalKrishna Oct 9, 2026
5b224d9
IDP-45: upload to EMBER-DANDI and import from it
D-GopalKrishna Oct 10, 2026
49eb19a
IDP-45: clearer names for the OSB URL settings; no bucket name in docs
D-GopalKrishna Oct 10, 2026
e67f9f4
IDP-45: share a copy with MAABCD, signed with a Keycloak user's key
D-GopalKrishna Oct 10, 2026
7316be9
IDP-45: keep the OSB login state current
D-GopalKrishna Oct 10, 2026
20b7e4c
IDP-45: tidy comments design-doc references
D-GopalKrishna Oct 10, 2026
9d64778
IDP-45: publish the researcher's dandiset for a DOI at the end of a run
D-GopalKrishna Oct 10, 2026
e2daf9b
IDP-45: show OSB as logged in only while there is a token
D-GopalKrishna Oct 10, 2026
f470d14
IDP-45: keep disabled primary buttons visible
D-GopalKrishna Oct 10, 2026
25a0bf7
IDP-45: Retry carries on where a run stopped; the dialog keeps its run
D-GopalKrishna Oct 10, 2026
0697910
IDP-45: clearer comments; drop an unused helper and a lint suppression
D-GopalKrishna Oct 10, 2026
c97cb6c
IDP-51: call OSB's /dandi upload endpoints; remove the old DANDI uplo…
D-GopalKrishna Oct 14, 2026
f8e68aa
IDP-51: publish button to publish and get a DOI for the uploaded data
D-GopalKrishna Oct 14, 2026
a947d87
IDP-51: rename the publish check by its role; document protocols and …
D-GopalKrishna Oct 22, 2026
a3c923a
IDP-52: publish once EMBER has checked every file
D-GopalKrishna Oct 22, 2026
a42476e
IDP-51: popup detection, bucket-mode login, tighter EMBER proxy paths
D-GopalKrishna Oct 26, 2026
0fb039a
IDP-51: check each recorded workspace instead of listing the first page
D-GopalKrishna Oct 26, 2026
eac97ff
IDP-51: fix EMBER api link to const; read every page of the user's da…
D-GopalKrishna Oct 26, 2026
c24a990
IDP-51: share with MAABCD as its own step, with Retry
D-GopalKrishna Oct 26, 2026
e8555e2
IDP-52: keep the run across a page reload; Retry on the failed step's…
D-GopalKrishna Oct 26, 2026
aab0205
IDP-52: choose the upload backend at build time; independent flow for…
D-GopalKrishna Oct 26, 2026
19f0080
IDP-52: log clearly when the chosen upload backend isn't set up
D-GopalKrishna Oct 26, 2026
404f908
IDP-52: 404 page for unknown URLs; fix EMBER-DANDI left in bucket mode
D-GopalKrishna Oct 27, 2026
4e924fd
IDP-52: stopped runs name the step and why; Retry on the row; plainer…
D-GopalKrishna Oct 27, 2026
8f99029
IDP-52: fix the path/run folder when the imports wait fails for OSB …
D-GopalKrishna Oct 27, 2026
80a2430
IDP-52: an ended OSB session stops the run in place; it reopens after…
D-GopalKrishna Oct 27, 2026
5129b47
IDP-52: fix silent fail of OSB's sign-in when can't be reached
D-GopalKrishna Oct 27, 2026
a640075
IDP-52: fix the dandiset picker making a call per dandiset and per wo…
D-GopalKrishna Oct 27, 2026
fac9c49
IDP-52: read a version's status, not just the draft's
D-GopalKrishna Oct 27, 2026
f3e6a25
fixing conditional destination for ember
ddelpiano Oct 27, 2026
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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,17 @@ Unit tests (no browser, no backend):
yarn test:unit
```

Uploads (Scenario 1 of the MAABCD–OSB integration) go from the browser straight to a public bucket
open for anonymous writes, named by `VITE_UPLOAD_BUCKET_URL` (e.g. `https://storage.googleapis.com/maabcd`),
as `uploads/<protocolId>/<userSub>/<uploadId>/<filename>`. The bucket's CORS must allow PUT from this app's origin.
Uploads go to one of two places, chosen by `UPLOAD_BACKEND` in `src/app/container.ts`. Either
way, uploading needs both logins (EMBER-DANDI and OSB), and OSB imports the file into the
researcher's workspace from there.

- **`ember`** (default): EMBER-DANDI, into the researcher's own dandiset and the protocol's
MAABCD dandiset (`maabcdDandisetId` in `src/data/protocols.json`). Needs `VITE_EMBER_CLIENT_ID`,
an EMBER-DANDI OAuth application (public client, PKCE) with `<this app's origin>/ember-callback`
as a redirect URI, and, for the MAABCD copy, the
`DANDI_API_KEY` attribute on the OSB user named by `MAABCD_EMBER_USERNAME` (`container.ts`).
- **`bucket`**: a public bucket open for anonymous writes, named by `VITE_UPLOAD_BUCKET_URL`.
Its CORS must allow PUT from this app's origin.

---

Expand Down
3 changes: 3 additions & 0 deletions applications/idp-arc/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ COPY frontend/ ./
# Baked into the bundle by Vite at build time; the Codefresh pipeline passes it as a build argument.
ARG VITE_UPLOAD_BUCKET_URL
ENV VITE_UPLOAD_BUCKET_URL=${VITE_UPLOAD_BUCKET_URL}
# EMBER-DANDI OAuth application (public client, PKCE) registered for this IDP origin.
ARG VITE_EMBER_CLIENT_ID
ENV VITE_EMBER_CLIENT_ID=${VITE_EMBER_CLIENT_ID}
RUN yarn build

FROM nginxinc/nginx-unprivileged:stable-alpine
Expand Down
8 changes: 8 additions & 0 deletions applications/idp-arc/deploy/resources/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ server {
try_files $uri $uri/ /index.html;
}

# EMBER's POST /oauth/token/ reply has no CORS header, so the browser can't read the login token
# from it directly; through this same-origin proxy it can. (File bytes go straight to S3, not here.)
location /ember-proxy/ {
proxy_pass https://api-dandi.emberarchive.org/;
Comment thread
ddelpiano marked this conversation as resolved.
Outdated
proxy_ssl_server_name on;
proxy_set_header Host api-dandi.emberarchive.org;
}

location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|webp|woff2?)$ {
try_files $uri =404;
expires 7d;
Expand Down
1 change: 1 addition & 0 deletions applications/idp-arc/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>MAABCD</title>
</head>
<body>
Expand Down
30 changes: 30 additions & 0 deletions applications/idp-arc/frontend/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
108 changes: 83 additions & 25 deletions applications/idp-arc/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,55 @@ import { useTranslation } from 'react-i18next'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { ThemeProvider } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import Typography from '@mui/material/Typography'

import { AppContext } from './AppContext'
import { authClient } from './app/container'
import { authClient, EMBER_CALLBACK_PATH } from './app/container'
import { theme } from './theme/components'
import type { AuthState } from './core/types'
import Home from './pages/LandingPage'
import Workspaces from './pages/Workspaces'
import ProtocolsPage from './pages/ProtocolsPage'
import AboutPage from './pages/AboutPage'
import EmberCallback from './pages/EmberCallback'

/**
* This window is OSB's (Keycloak) login popup, sent back here once the user has signed in. It only
* finishes the sign-in, hands it to the main tab and closes; it never shows the app (it would
* otherwise reopen the Login dialog: a popup starts with a copy of its opener's sessionStorage).
* EMBER-DANDI's popup has no `window.opener` (COOP) and is handled on its callback route.
*/
const IN_OSB_LOGIN_POPUP = typeof window !== 'undefined' && !!window.opener
Comment thread
ddelpiano marked this conversation as resolved.
Outdated
&& window.location.pathname !== EMBER_CALLBACK_PATH

/** What the OSB login popup shows while it finishes the sign-in and closes itself. */
function OsbLoginPopupScreen({ authState }: { authState: AuthState }) {
const { t } = useTranslation('landingPage')
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2, p: 4, textAlign: 'center' }}>
{authState === 'loading' || authState === 'authenticated' ? (
<>
<CircularProgress size={28} />
<Typography variant="body2" sx={{ opacity: 0.7 }}>{t('loginDialog.finishingSignIn')}</Typography>
</>
) : (
<Typography variant="body1">{t('loginDialog.closeWindow')}</Typography>
)}
</Box>
</ThemeProvider>
)
}

const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{ path: '/workspaces', element: <Workspaces /> },
{ path: '/protocols', element: <ProtocolsPage /> },
{ path: '/about', element: <AboutPage /> },
{ path: EMBER_CALLBACK_PATH, element: <EmberCallback /> },
])

function App() {
Expand All @@ -29,31 +63,53 @@ function App() {

useEffect(() => {
if (initialized.current) return
initialized.current = true

authClient
.init()
.then((authenticated: boolean) => {
// If running inside the login popup, signal the parent and close.
if (authenticated && window.opener) {
window.opener.postMessage({ type: 'keycloak-login-complete' }, window.location.origin)
window.close()
return
}
setAuthState(authenticated ? 'authenticated' : 'unauthenticated')
if (authenticated && authClient.tokenParsed) {
setTokenParsed(authClient.tokenParsed)
}
})
.catch((err: Error) => {
// Clear stale auth fragment so a page refresh doesn't retry a failed token exchange.
if (window.location.hash) {
window.history.replaceState(null, '', window.location.pathname + window.location.search)
}
console.error('Keycloak init failed', err)
setAuthError(t('errors.authFailed'))
setAuthState('unauthenticated')
})
// Keycloak has no business on the EMBER-DANDI callback route: its check-sso can fall back to a
// real top-level redirect, which would abort the EMBER token exchange running there. It
// starts once the callback page has navigated back (router.subscribe: App renders the
// RouterProvider, so it isn't inside it and can't useLocation).
const tryInit = () => {
if (initialized.current) return
if (router.state.location.pathname === EMBER_CALLBACK_PATH) return
initialized.current = true
unsubscribe()
startKeycloak()
}
const unsubscribe = router.subscribe(tryInit)
tryInit()
return () => unsubscribe()

function startKeycloak() {
authClient
.init()
.then((authenticated: boolean) => {
// If running inside the login popup, signal the parent and close.
if (authenticated && window.opener) {
window.opener.postMessage({ type: 'keycloak-login-complete' }, window.location.origin)
window.close()
return
}
setAuthState(authenticated ? 'authenticated' : 'unauthenticated')
// The OSB session can end later (its refresh refused): show it as signed out then, so
// the Login dialog offers OSB's login again instead of "Logged in".
authClient.onSessionEnded(() => {
setAuthState('unauthenticated')
setTokenParsed(null)
})
if (authenticated && authClient.tokenParsed) {
setTokenParsed(authClient.tokenParsed)
}
})
.catch((err: Error) => {
// Clear stale auth fragment so a page refresh doesn't retry a failed token exchange.
if (window.location.hash) {
window.history.replaceState(null, '', window.location.pathname + window.location.search)
}
console.error('Keycloak init failed', err)
setAuthError(t('errors.authFailed'))
setAuthState('unauthenticated')
})
}
}, [t])

// Reload after popup login so check-sso picks up the new Keycloak session.
Expand All @@ -77,6 +133,8 @@ function App() {
[authState, tokenParsed, authError, username],
)

if (IN_OSB_LOGIN_POPUP) return <OsbLoginPopupScreen authState={authState} />

return (
<AppContext.Provider value={appContextValue}>
<ThemeProvider theme={theme}>
Expand Down
84 changes: 67 additions & 17 deletions applications/idp-arc/frontend/src/app/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,41 @@

import { KeycloakAuthClient } from '../infra/keycloakAuthClient'
import { WorkspaceApiClient } from '../infra/workspaceApiClient'
import { DandiApiClient } from '../infra/dandiApiClient'
import { PublicBucketObjectStore } from '../infra/publicBucketObjectStore'
import { EmberOAuthClient, EMBER_CALLBACK_PATH } from '../infra/emberOAuthClient'
import { EmberDandiDirectClient } from '../infra/emberDandiDirectClient'
import { EmberUploadApiClient } from '../infra/emberUploadApiClient'
import { EMBER_API_ORIGIN_DEFAULT, EMBER_WEB_ORIGIN } from '../infra/emberUrls'
import type { IObjectStore } from '../core/ports/IObjectStore'
import { createEmberObjectStore } from '../core/use-cases/emberObjectStore'
import { createProtocolDandisetsUseCases, type ProtocolDandiset } from '../core/use-cases/protocolDandisets'
import { createLoadWorkspacesUseCase } from '../core/use-cases/loadWorkspaces'
import { createCreateAndUploadToDandiUseCase } from '../core/use-cases/createAndUploadToDandi'
import { createCreateWorkspaceUseCase } from '../core/use-cases/createWorkspace'
import { createRunProtocolUseCase } from '../core/use-cases/runProtocol'

// ─── Config ───────────────────────────────────────────────────────────────────
// All environment-specific URLs live here (or read from import.meta.env in Vite).

const BASE_DOMAIN = import.meta.env.VITE_OSB_BASE_DOMAIN ?? 'v2dev.opensourcebrain.org'
const PROTOCOL = import.meta.env.VITE_OSB_PROTOCOL ?? 'https'
const WWW_BASE = import.meta.env.DEV ? '/api-proxy' : `${PROTOCOL}://www.${BASE_DOMAIN}`
const WORKSPACES_API = `${WWW_BASE}/proxy/workspaces/api`
const OSB_DOMAIN = import.meta.env.VITE_OSB_BASE_DOMAIN ?? 'v2dev.opensourcebrain.org'
const OSB_SCHEME = import.meta.env.VITE_OSB_PROTOCOL ?? 'https'
const OSB_API_BASE = import.meta.env.DEV ? '/api-proxy' : `${OSB_SCHEME}://www.${OSB_DOMAIN}`
const WORKSPACES_API = `${OSB_API_BASE}/proxy/workspaces/api`
const WORKSPACES_LIST_URL =
`${WWW_BASE}/proxy/workspaces/api/workspace?page=1&per_page=24&q=&tags=`
const FRONTEND_BASE = `${PROTOCOL}://www.${BASE_DOMAIN}`
`${OSB_API_BASE}/proxy/workspaces/api/workspace?page=1&per_page=24&q=&tags=`
const OSB_WEB_ORIGIN = `${OSB_SCHEME}://www.${OSB_DOMAIN}`

/** Where uploads go: `ember` (EMBER-DANDI) or `bucket` (the public bucket named by
* VITE_UPLOAD_BUCKET_URL). Change it here to switch. */
export const UPLOAD_BACKEND = 'ember' as 'bucket' | 'ember'
/** The OSB (Keycloak) user whose key (user attribute `DANDI_API_KEY`) signs the copy into the
* protocol's MAABCD dandiset, and the DANDI deployment it goes to (dandi-cli's name). */
const MAABCD_EMBER_USERNAME = 'maabcd-consortium'
const MAABCD_DANDI_INSTANCE = 'ember-dandi'
/** EMBER-DANDI's API origin: where the OAuth login goes, and what OSB downloads assets from. */
const EMBER_ORIGIN = (import.meta.env.VITE_EMBER_ORIGIN ?? EMBER_API_ORIGIN_DEFAULT).replace(/\/+$/, '')
/** Same-origin path for the browser's own EMBER calls (Vite proxy in dev, nginx deployed): EMBER's
* token response carries no CORS headers. */
const EMBER_FETCH_BASE = '/ember-proxy'

// ─── Infrastructure singletons ────────────────────────────────────────────────

Expand All @@ -48,10 +65,27 @@ export const authClient = new KeycloakAuthClient({
const workspaceApi = new WorkspaceApiClient(WORKSPACES_API, WORKSPACES_LIST_URL)
// DANDI upload endpoints live in OSB's `workspaces` app (the admin key has to sit wherever
// they run) — same API base as every other workspace call.
const dandiApi = new DandiApiClient(WORKSPACES_API)

/** Scenario 1: where uploads go before OSB imports them (`gs://maabcd`). Used by the upload flow. */
export const objectStore: IObjectStore = new PublicBucketObjectStore(import.meta.env.VITE_UPLOAD_BUCKET_URL)
/** Signs the researcher in to EMBER-DANDI (OAuth in a popup, a full-page redirect if it is blocked);
* their token stays in the browser. */
export const emberAuth = new EmberOAuthClient({
authOrigin: EMBER_ORIGIN,
apiBase: EMBER_FETCH_BASE,
clientId: import.meta.env.VITE_EMBER_CLIENT_ID ?? '',
redirectUri: `${window.location.origin}${EMBER_CALLBACK_PATH}`,
})
export { EMBER_CALLBACK_PATH }
export { EmberPopupBlocked, EmberSignInCancelled } from '../infra/emberOAuthClient'
const emberDirect = new EmberDandiDirectClient(EMBER_FETCH_BASE)
/** The researcher's dandisets by protocol, and the workspace each runs in (dandiset metadata). */
const protocolDandisets = createProtocolDandisetsUseCases({
emberAuth, direct: emberDirect, osbDomain: OSB_DOMAIN, workspaceUrl: (id) => getWorkspaceUrl(id),
})

/** Where uploads go before OSB imports them (UPLOAD_BACKEND): EMBER-DANDI, or the public bucket. */
export const objectStore: IObjectStore = UPLOAD_BACKEND === 'ember'
? createEmberObjectStore(authClient, emberAuth, emberDirect, new EmberUploadApiClient(WORKSPACES_API, MAABCD_DANDI_INSTANCE, MAABCD_EMBER_USERNAME), protocolDandisets, EMBER_ORIGIN)
: new PublicBucketObjectStore(import.meta.env.VITE_UPLOAD_BUCKET_URL)

// ─── Use-cases (injected with their concrete dependencies) ────────────────────

Expand All @@ -62,19 +96,35 @@ export const loadWorkspaces = createLoadWorkspacesUseCase(authClient, workspaceA
export const createWorkspace = createCreateWorkspaceUseCase(authClient, workspaceApi)

/** Uploads the researcher's file, imports it and the protocol's repository into the workspace
* through OSB, and runs the notebooks in OSB's Argo task (MAABCD–OSB design, Scenario 1). */
* through OSB, and runs the notebooks in OSB's Argo task. */
export const runProtocol = createRunProtocolUseCase(authClient, workspaceApi, objectStore)

/** DANDI-backed upload (Route A, see IDP-43 notes); `finalize` also runs the selected
* protocol's script server-side (jupyter_kernel_client.py in OSBv2's workspaces app). */
export const createAndUploadToDandi = createCreateAndUploadToDandiUseCase(authClient, dandiApi)
/** The researcher's EMBER-DANDI dandisets for a protocol, each with the workspace it runs in when
* they still have it: what the upload dialog offers once a protocol is chosen. */
export async function listProtocolDandisets(protocolId: string): Promise<ProtocolDandiset[]> {
const ownWorkspaceIds = (await loadWorkspaces()).map((w) => Number(w.id))
Comment thread
ddelpiano marked this conversation as resolved.
Outdated
return protocolDandisets.listForProtocol(protocolId, ownWorkspaceIds)
}
export type { ProtocolDandiset }

/** Publishes the researcher's dandiset as a new version, for its DOI; only when they ask for it. */
export async function publishDandiset(dandisetId: string): Promise<{ doi: string } | null> {
const version = await protocolDandisets.publish(dandisetId)
return version?.doi ? { doi: version.doi } : null
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

/** The researcher's workspaces on OSB ("My workspaces"). */
export const OSB_WORKSPACES_URL = `${OSB_WEB_ORIGIN}/`

/** The signed-in researcher's own dandisets on EMBER-DANDI's website. */
export const MY_DANDISETS_URL = `${EMBER_WEB_ORIGIN}/dandiset/my`

/**
* Builds the public URL for a given workspace id.
* Centralised here so no component needs to know BASE_DOMAIN.
* Centralised here so no component needs to know OSB_DOMAIN.
*/
export function getWorkspaceUrl(workspaceId: number): string {
return `${FRONTEND_BASE}/workspaces/open/${workspaceId}/jupyter`
return `${OSB_WEB_ORIGIN}/workspaces/open/${workspaceId}/jupyter`
}
Loading
Loading