Skip to content
Merged
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
56 changes: 33 additions & 23 deletions plugins/network/src/NetworkApp.vue
Original file line number Diff line number Diff line change
@@ -1,36 +1,46 @@
<template>
<AtlasPageShell
hero
compact
eyebrow="OHDSI · Network"
title="Network"
subtitle="Manage federated network studies — execute, submit results, and register this site."
>
<div class="network-app-body">
<SectionHero
eyebrow="OHDSI · Network"
:title="isConfig ? 'Configuration' : 'Network'"
:subtitle="isConfig
? 'Register and manage this site’s connection to the research network.'
: 'Run federated network studies against this site and track your submissions.'"
/>
<!-- No in-plugin login: access is gated by the trex session, and all API
calls go through the network-api function proxy, which attaches the
site's machine token server-side. -->
<AtlasTabs v-model="tab" class="mb-4">
<AtlasTab value="studies">Studies</AtlasTab>
<AtlasTab value="submit">Submit</AtlasTab>
<AtlasTab value="mine">My submissions</AtlasTab>
<AtlasTab value="register">Register site</AtlasTab>
</AtlasTabs>
<v-window v-model="tab">
<v-window-item value="studies"><StudiesToExecuteView /></v-window-item>
<v-window-item value="submit"><SubmitResultsView /></v-window-item>
<v-window-item value="mine"><MySubmissionsView /></v-window-item>
<v-window-item value="register"><RegisterSiteView /></v-window-item>
</v-window>
</AtlasPageShell>
<RegisterSiteView v-if="isConfig" />
<template v-else>
<StudiesToExecuteView />
<AtlasDivider class="network-app__divider" />
<SubmitResultsView />
<AtlasDivider class="network-app__divider" />
<MySubmissionsView />
</template>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { AtlasPageShell, AtlasTabs, AtlasTab } from '@ohdsi/atlas-ui';
import { computed } from 'vue';
import { AtlasDivider } from '@ohdsi/atlas-ui';
import SectionHero from './components/SectionHero.vue';
import StudiesToExecuteView from './views/StudiesToExecuteView.vue';
import SubmitResultsView from './views/SubmitResultsView.vue';
import MySubmissionsView from './views/MySubmissionsView.vue';
import RegisterSiteView from './views/RegisterSiteView.vue';

const tab = ref('studies');
const props = withDefaults(defineProps<{ section?: 'main' | 'configuration' }>(), {
section: 'main',
});
const isConfig = computed(() => props.section === 'configuration');
</script>

<style scoped>
.network-app__divider {
margin: 28px 0;
}
.network-app-body {
padding: 20px 24px;
}
</style>
71 changes: 66 additions & 5 deletions plugins/network/src/api/authToken.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,70 @@
// Shared auth token for backend calls. Set by main.ts from the host's
// authContext on mount; read by ApiClient + HadesClient. Replaces the
// () => null stub — Atlas3 provides a Bearer token instead.
// authContext on mount; read by graphqlClient + hadesClient. Replaces the
// sibyl session-cookie assumption — Atlas3 provides a Bearer token instead.
//
// Under a d2e/Atlas host the token is a Logto RS256 access token, which trex
// core's HS256-only auth middleware rejects — requests would run as the
// grant-less `anon` Postgres role. ensureAuthToken() exchanges it via the
// /trex-token function for a trex-native token before it is sent. Tokens that
// already carry aud "authenticated" are trex-native and are sent unchanged.
let token: string | null = null;
export function setAuthToken(t: string | null): void { token = t; }
export function getAuthToken(): string | null { return token; }
let trexToken: string | null = null;
let exchange: Promise<string | null> | null = null;

export function setAuthToken(t: string | null): void {
token = t;
trexToken = null;
exchange = null;
}

function jwtPayload(t: string): Record<string, unknown> | null {
try {
const b64 = t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(b64)) as Record<string, unknown>;
} catch {
return null;
}
}

function expiresSoon(t: string): boolean {
const exp = jwtPayload(t)?.exp;
return typeof exp === 'number' && exp * 1000 < Date.now() + 30_000;
}

async function exchangeToken(t: string): Promise<string | null> {
try {
const resp = await fetch(`${location.origin}/trex-token`, {
method: 'POST',
headers: { Authorization: `Bearer ${t}` },
});
if (!resp.ok) return null;
const body = await resp.json();
return typeof body.access_token === 'string' ? body.access_token : null;
} catch {
return null;
}
}

/** Resolve the token authHeaders() will send; await before any backend request. */
export async function ensureAuthToken(): Promise<void> {
if (!token) return;
const payload = jwtPayload(token);
if (!payload || payload.aud === 'authenticated') return;
if (trexToken && !expiresSoon(trexToken)) return;
if (!exchange) {
exchange = exchangeToken(token).finally(() => {
exchange = null;
});
}
const p = exchange;
trexToken = (await p) ?? trexToken;
}

export function getAuthToken(): string | null {
return trexToken ?? token;
}

export function authHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
const t = trexToken ?? token;
return t ? { Authorization: `Bearer ${t}` } : {};
}
3 changes: 3 additions & 0 deletions plugins/network/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export class ApiClientError extends Error {
}
}

import { ensureAuthToken } from './authToken';

type Fetch = typeof fetch;

export class ApiClient {
Expand All @@ -18,6 +20,7 @@ export class ApiClient {
) {}

private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
await ensureAuthToken();
const headers: Record<string, string> = {};
const token = this.getToken();
if (token) headers.authorization = `Bearer ${token}`;
Expand Down
3 changes: 2 additions & 1 deletion plugins/network/src/api/hadesClient.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { HadesJobDetail, RunRequest } from './hadesTypes';
import { authHeaders } from './authToken';
import { authHeaders, ensureAuthToken } from './authToken';

export class HadesClient {
constructor(private base: string, private fetchImpl: typeof fetch = fetch.bind(globalThis)) {}

private async req<T>(path: string, init?: RequestInit): Promise<T> {
await ensureAuthToken();
const resp = await this.fetchImpl(`${this.base}${path}`, {
headers: { 'Content-Type': 'application/json', ...authHeaders() },
...init,
Expand Down
75 changes: 75 additions & 0 deletions plugins/network/src/components/SectionHero.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<template>
<div class="section-hero">
<div class="section-hero__eyebrow-row">
<span class="section-hero__eyebrow">{{ eyebrow }}</span>
<span class="section-hero__accent" />
</div>
<div class="section-hero__head">
<div>
<h1 class="section-hero__title">{{ title }}</h1>
<p v-if="subtitle" class="section-hero__subtitle">{{ subtitle }}</p>
</div>
<div class="section-hero__actions"><slot name="actions" /></div>
</div>
</div>
</template>

<script setup lang="ts">
defineProps<{ eyebrow: string; title: string; subtitle?: string }>();
</script>

<style scoped>
/* Flat rendition of the AtlasPageShell hero header (same metrics as
atlas-ui's page-header) — no surface of its own, so it can sit inside the
StudiesLayout card without nesting boxes. */
.section-hero {
margin-bottom: 20px;
}
.section-hero__eyebrow-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.section-hero__eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgb(var(--v-theme-primary));
}
.section-hero__accent {
display: inline-block;
width: 28px;
height: 2px;
background: rgb(var(--v-theme-orange, 235, 102, 34));
border-radius: 2px;
}
.section-hero__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.section-hero__title {
font-size: 26px;
font-weight: 300;
line-height: 1.2;
letter-spacing: 0.01em;
color: rgb(var(--v-theme-primary));
margin: 0;
}
.section-hero__subtitle {
font-size: 13px;
color: rgb(var(--v-theme-on-surface-variant));
margin: 4px 0 0;
max-width: 640px;
line-height: 1.5;
}
.section-hero__actions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
</style>
2 changes: 1 addition & 1 deletion plugins/network/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function loadConfig(): NetworkConfig {
injected.proxyUrl ??
env.VITE_PROXY_URL ??
(typeof location !== 'undefined'
? `${location.origin}/plugins/network-api/network-api`
? `${location.origin}/network-api`
: ''),
};
}
3 changes: 2 additions & 1 deletion plugins/network/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface PluginProps {
uiFilesUrl?: string;
authContext?: unknown;
messageBus?: unknown;
section?: 'main' | 'configuration';
}

function getSharedDefaults(): Record<string, Record<string, unknown>> {
Expand Down Expand Up @@ -75,7 +76,7 @@ const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
return h(NetworkApp);
return h(NetworkApp, { section: (this as PluginProps).section });
},
},
handleInstance(app) {
Expand Down
52 changes: 36 additions & 16 deletions plugins/notebook-plugin/src/NotebookApp.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
<template>
<v-app class="notebook-plugin">
<v-main>
<!-- List view renders AtlasPageShell, which supplies its own page padding
(matching every other plugin). Keep inner padding for the editor. -->
<NotebookListView
v-if="view === 'list'"
@open="openNotebook"
@new="newNotebook"
/>
<div
v-else
class="pa-4"
>
<NotebookEditorView
:id="activeId"
@back="goToList"
@saved="onSaved"
/>
<!-- Single boxed surface (sidebar-less) matching the Studies layout: one
card with a padded, scrollable body. Each view renders its own flat
SectionHero inside — no nested page-card. -->
<div class="notebook-layout">
<div class="notebook-shell">
<NotebookListView
v-if="view === 'list'"
@open="openNotebook"
@new="newNotebook"
/>
<NotebookEditorView
v-else
:id="activeId"
@back="goToList"
@saved="onSaved"
/>
</div>
</div>
</v-main>
</v-app>
Expand Down Expand Up @@ -90,4 +91,23 @@ onMounted(() => {

<style scoped>
.notebook-plugin { background: transparent; }

/* Mirrors the Studies layout so the notebook plugin reads as the same product:
a single rounded card floating on the page background, its body owning the
scroll. Sidebar-less (the notebook is its own full detail). */
.notebook-layout {
height: calc(100vh - 60px);
padding: 24px;
background: rgb(var(--v-theme-background));
overflow: hidden;
}
.notebook-shell {
display: flex;
flex-direction: column;
background: rgb(var(--v-theme-surface));
border-radius: 12px;
box-shadow: 0 1px 3px rgba(15, 23, 42, .08), 0 8px 24px rgba(15, 23, 42, .04);
height: 100%;
overflow: hidden;
}
</style>
Loading
Loading