From b2ddd6e93356107b4eab32d15b75dc57876efc9a Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 16 Oct 2026 12:37:27 +0300 Subject: [PATCH 1/5] feat(account): add a guide for using MIP from your own computer Shown when the notebook is enabled. Links to the Notebook page so Hub keeps the sign-in, opens Hub's /hub/token page in a new tab to create a personal API token, and offers a copyable MIP_URL/MIP_API_TOKEN setup snippet. The UI never requests, stores or shows the token itself. Co-Authored-By: Claude --- docs/llm-wiki/08-auth-and-terms-flow.md | 1 + .../account-page/account-page.component.css | 86 +++++++++++++++++++ .../account-page/account-page.component.html | 34 ++++++++ .../account-page.component.spec.ts | 55 ++++++++++++ .../account-page/account-page.component.ts | 25 +++++- 5 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 src/app/pages/account-page/account-page.component.spec.ts diff --git a/docs/llm-wiki/08-auth-and-terms-flow.md b/docs/llm-wiki/08-auth-and-terms-flow.md index bea4ac16..25347791 100644 --- a/docs/llm-wiki/08-auth-and-terms-flow.md +++ b/docs/llm-wiki/08-auth-and-terms-flow.md @@ -40,6 +40,7 @@ Platform UI auth and JupyterHub auth are related but separate: 8. Hub sets its own session cookie (SameSite=Lax, Secure by default). Any valid Keycloak user for the Hub client is currently allowed (`allow_all = True`). 9. Before spawn, Hub injects the Keycloak access token into the notebook pod as `MIP_TOKEN`, refreshing via stored refresh tokens when needed. 10. Running notebooks can refresh `MIP_TOKEN` through Hub `/notebook/hub/api/platform-token` via the mip client transport. +11. Remote use (account page, notebook flag only): the user creates a personal Hub API token at `/notebook/hub/token` (a top-level navigation that nginx proxies to Hub, not back into the shell) and sets `MIP_URL`/`MIP_API_TOKEN` for the mip client on their own computer. The UI only links to Hub's page; it never requests, stores or shows the token. Key files: `src/app/pages/notebook/hub-session.ts`, `hub-http.ts`, `jupyterhub-api.ts`, `nginx.conf.template`. diff --git a/src/app/pages/account-page/account-page.component.css b/src/app/pages/account-page/account-page.component.css index 50dd97ef..0cce1cc1 100644 --- a/src/app/pages/account-page/account-page.component.css +++ b/src/app/pages/account-page/account-page.component.css @@ -487,3 +487,89 @@ min-width: 0; } } + +/* ===== Use MIP from your own computer ===== */ +.remote-steps { + margin: 0; + padding-left: 20px; + font-size: 14px; + color: #111827; + line-height: 1.5; +} + +.remote-steps li + li { + margin-top: 14px; +} + +.remote-steps p { + margin: 8px 0 0; + color: #6b7280; +} + +.remote-steps pre { + margin: 8px 0 0; + padding: 10px 12px; + border-radius: 10px; + background: #f9fafb; + border: 1px solid #e5e7eb; + font-family: var(--font-mono); + font-size: 13px; + overflow-x: auto; +} + +.remote-steps code { + font-family: var(--font-mono); +} + +.token-btn { + display: inline-flex; + align-items: center; + gap: 8px; + background-color: #18a2b8; + color: #ffffff; + padding: 8px 14px; + border-radius: 999px; + font-size: 14px; + font-weight: 500; + text-decoration: none; +} + +.token-btn:hover { + background-color: #14889a; +} + +.snippet { + position: relative; +} + +.snippet pre { + padding-right: 88px; +} + +.copy-btn { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + gap: 6px; + background: #ffffff; + color: #0f172a; + border: 1px solid #d1d5db; + padding: 4px 10px; + border-radius: 999px; + cursor: pointer; + font-size: 13px; +} + +.copy-btn:hover { + background: #f8fafc; +} + +.copy-status { + display: block; + min-height: 1em; + margin-top: 4px; + font-size: 12px; + color: #6b7280; +} diff --git a/src/app/pages/account-page/account-page.component.html b/src/app/pages/account-page/account-page.component.html index b797c072..d7ca1c1b 100644 --- a/src/app/pages/account-page/account-page.component.html +++ b/src/app/pages/account-page/account-page.component.html @@ -43,6 +43,40 @@

Profile Information

+ + @if (notebookEnabled) { +
+

Use MIP from your own computer

+
    +
  1. + Open the Notebook page once so your sign-in is saved. +
  2. +
  3. + + + Create API token + +

    + Tick only the permission custom:mip-platform-token and set an expiry. + The token is shown once. You can revoke it on the same page. +

    +
  4. +
  5. + In a terminal on your computer, run: +
    +
    {{ setupSnippet }}
    + +
    + {{ copyStatus() }} +

    Then, in Python:

    +
    import mip; client = mip.Client.from_env()
    +
  6. +
+
+ } \ No newline at end of file diff --git a/src/app/pages/account-page/account-page.component.spec.ts b/src/app/pages/account-page/account-page.component.spec.ts new file mode 100644 index 00000000..3c76f000 --- /dev/null +++ b/src/app/pages/account-page/account-page.component.spec.ts @@ -0,0 +1,55 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { AccountPageComponent } from './account-page.component'; +import { AuthService } from '../../services/auth.service'; +import { RuntimeEnvService } from '../../services/runtime-env.service'; +import { createSpyObj } from '../../testing/spy-obj.testing'; + +describe('AccountPageComponent', () => { + function render(runtimeEnv: Partial) { + const authService = createSpyObj('AuthService', ['logout'], { + authState: signal({ status: 'authenticated', user: { fullname: 'Marie Curie', email: 'm@c.eu' } }), + }); + TestBed.configureTestingModule({ + imports: [AccountPageComponent], + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + { provide: AuthService, useValue: authService }, + { provide: RuntimeEnvService, useValue: runtimeEnv }, + ], + }); + const fixture = TestBed.createComponent(AccountPageComponent); + fixture.detectChanges(); + return fixture; + } + + it('hides the remote access section when the notebook is disabled', () => { + const el: HTMLElement = render({ notebookEnabled: false, jupyterContextPath: '/notebook' }).nativeElement; + + expect(el.querySelector('.remote-access')).toBeNull(); + }); + + it('links to the Hub token page under the configured context path', () => { + const el: HTMLElement = render({ notebookEnabled: true, jupyterContextPath: '/jupyter' }).nativeElement; + + const tokenLink = el.querySelector('.token-btn')!; + expect(tokenLink.getAttribute('href')).toBe('/jupyter/hub/token'); + expect(tokenLink.target).toBe('_blank'); + expect(el.querySelector('a[href="/notebook"]')).not.toBeNull(); + }); + + it('copies a setup snippet built from the current origin, with no real token', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + const fixture = render({ notebookEnabled: true, jupyterContextPath: '/notebook' }); + + (fixture.nativeElement.querySelector('.copy-btn') as HTMLButtonElement).click(); + await fixture.whenStable(); + + expect(writeText).toHaveBeenCalledWith( + `pip install mip-client\nexport MIP_URL=${window.location.origin}\nexport MIP_API_TOKEN=`, + ); + expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Copied'); + }); +}); diff --git a/src/app/pages/account-page/account-page.component.ts b/src/app/pages/account-page/account-page.component.ts index ef88192e..87fb3e68 100644 --- a/src/app/pages/account-page/account-page.component.ts +++ b/src/app/pages/account-page/account-page.component.ts @@ -1,7 +1,8 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { CommonModule, Location } from '@angular/common'; import { RouterModule } from '@angular/router'; import { AuthService } from "../../services/auth.service"; +import { RuntimeEnvService } from '../../services/runtime-env.service'; @Component({ selector: 'app-account-page', @@ -13,10 +14,22 @@ import { AuthService } from "../../services/auth.service"; export class AccountPageComponent { public authService = inject(AuthService); private location = inject(Location); + private runtimeEnv = inject(RuntimeEnvService); userName = computed(() => this.authService.authState().user?.fullname ?? ''); userEmail = computed(() => this.authService.authState().user?.email ?? ''); + // Remote access goes through JupyterHub, which holds the user's Keycloak login; + // the token is created on Hub's own page, so this UI never sees it. + readonly notebookEnabled = this.runtimeEnv.notebookEnabled; + readonly hubTokenUrl = `${this.runtimeEnv.jupyterContextPath}/hub/token`; + readonly setupSnippet = [ + 'pip install mip-client', + `export MIP_URL=${window.location.origin}`, + 'export MIP_API_TOKEN=', + ].join('\n'); + readonly copyStatus = signal(''); + constructor() { } signOut(): void { @@ -26,4 +39,14 @@ export class AccountPageComponent { goBack(): void { this.location.back(); } + + async copySetupSnippet(): Promise { + try { + await navigator.clipboard.writeText(this.setupSnippet); + this.copyStatus.set('Copied'); + } catch (err) { + console.warn('Failed to copy setup snippet:', err); + this.copyStatus.set('Could not copy, select the text instead'); + } + } } From ab28343200752ad8773997f9a1fd1379eb817715 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 16 Oct 2026 16:18:58 +0300 Subject: [PATCH 2/5] fix(account): token permission is typed into a text box, not ticked --- src/app/pages/account-page/account-page.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pages/account-page/account-page.component.html b/src/app/pages/account-page/account-page.component.html index d7ca1c1b..2d9d7734 100644 --- a/src/app/pages/account-page/account-page.component.html +++ b/src/app/pages/account-page/account-page.component.html @@ -57,7 +57,7 @@

Use MIP from your own computer

Create API token

- Tick only the permission custom:mip-platform-token and set an expiry. + In Permissions, enter custom:mip-platform-token and choose an expiry. The token is shown once. You can revoke it on the same page.

From 29a71b13d080efadec3d704888a45ae474045f19 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Mon, 19 Oct 2026 11:23:56 +0300 Subject: [PATCH 3/5] fix(account): keep the setup snippet valid shell and label its controls Quote the MIP_API_TOKEN placeholder so pasting it unedited is not parsed as a redirection, say Python must run in the same terminal as the exports, give the copy button and the new-tab token link screen-reader labels, and test the clipboard failure message. Co-Authored-By: Claude --- .../pages/account-page/account-page.component.html | 8 +++++--- .../account-page/account-page.component.spec.ts | 13 ++++++++++++- .../pages/account-page/account-page.component.ts | 3 ++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/app/pages/account-page/account-page.component.html b/src/app/pages/account-page/account-page.component.html index 2d9d7734..de77e7b2 100644 --- a/src/app/pages/account-page/account-page.component.html +++ b/src/app/pages/account-page/account-page.component.html @@ -52,7 +52,8 @@

Use MIP from your own computer

Open the Notebook page once so your sign-in is saved.
  • - + Create API token @@ -65,13 +66,14 @@

    Use MIP from your own computer

    In a terminal on your computer, run:
    {{ setupSnippet }}
    -
    {{ copyStatus() }} -

    Then, in Python:

    +

    Then start Python in the same terminal and run:

    import mip; client = mip.Client.from_env()
  • diff --git a/src/app/pages/account-page/account-page.component.spec.ts b/src/app/pages/account-page/account-page.component.spec.ts index 3c76f000..7e07ee4d 100644 --- a/src/app/pages/account-page/account-page.component.spec.ts +++ b/src/app/pages/account-page/account-page.component.spec.ts @@ -48,8 +48,19 @@ describe('AccountPageComponent', () => { await fixture.whenStable(); expect(writeText).toHaveBeenCalledWith( - `pip install mip-client\nexport MIP_URL=${window.location.origin}\nexport MIP_API_TOKEN=`, + `pip install mip-client\nexport MIP_URL=${window.location.origin}\nexport MIP_API_TOKEN=""`, ); expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Copied'); }); + + it('tells the user to copy by hand when the clipboard refuses', async () => { + vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fixture = render({ notebookEnabled: true, jupyterContextPath: '/notebook' }); + + (fixture.nativeElement.querySelector('.copy-btn') as HTMLButtonElement).click(); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Could not copy'); + }); }); diff --git a/src/app/pages/account-page/account-page.component.ts b/src/app/pages/account-page/account-page.component.ts index 87fb3e68..4ec590f3 100644 --- a/src/app/pages/account-page/account-page.component.ts +++ b/src/app/pages/account-page/account-page.component.ts @@ -26,7 +26,8 @@ export class AccountPageComponent { readonly setupSnippet = [ 'pip install mip-client', `export MIP_URL=${window.location.origin}`, - 'export MIP_API_TOKEN=', + // Quoted so the line stays valid shell; the user replaces the text inside the quotes. + 'export MIP_API_TOKEN=""', ].join('\n'); readonly copyStatus = signal(''); From 01d5b87157422b258b42e59d9b2d2419ca0e5e9e Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Mon, 19 Oct 2026 11:52:33 +0300 Subject: [PATCH 4/5] fix(account): drop the notebook step and tidy the remote access guide Opening the Notebook page starts a notebook server just to save the sign-in, and the Hub token page already signs the user in through Keycloak, so that step is gone. Add the PowerShell form of the exports, clear the copy status so a repeated copy is announced again, and style the token link as the primary action per DESIGN.md. Co-Authored-By: Claude --- .../pages/account-page/account-page.component.css | 6 +++--- .../pages/account-page/account-page.component.html | 7 ++++--- .../account-page/account-page.component.spec.ts | 14 +++++++++++++- .../pages/account-page/account-page.component.ts | 12 ++++++++++-- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/app/pages/account-page/account-page.component.css b/src/app/pages/account-page/account-page.component.css index 0cce1cc1..1dbdba37 100644 --- a/src/app/pages/account-page/account-page.component.css +++ b/src/app/pages/account-page/account-page.component.css @@ -525,17 +525,17 @@ display: inline-flex; align-items: center; gap: 8px; - background-color: #18a2b8; + background-color: var(--primary-color); color: #ffffff; padding: 8px 14px; - border-radius: 999px; + border-radius: var(--radius-md); font-size: 14px; font-weight: 500; text-decoration: none; } .token-btn:hover { - background-color: #14889a; + background-color: var(--primary-dark); } .snippet { diff --git a/src/app/pages/account-page/account-page.component.html b/src/app/pages/account-page/account-page.component.html index de77e7b2..0605a95d 100644 --- a/src/app/pages/account-page/account-page.component.html +++ b/src/app/pages/account-page/account-page.component.html @@ -48,9 +48,6 @@

    Profile Information

    Use MIP from your own computer

      -
    1. - Open the Notebook page once so your sign-in is saved. -
    2. @@ -73,6 +70,10 @@

      Use MIP from your own computer

      {{ copyStatus() }} +

      + In Windows PowerShell, write $env:MIP_URL = "…" instead of + export MIP_URL=…, and the same for MIP_API_TOKEN. +

      Then start Python in the same terminal and run:

      import mip; client = mip.Client.from_env()
    3. diff --git a/src/app/pages/account-page/account-page.component.spec.ts b/src/app/pages/account-page/account-page.component.spec.ts index 7e07ee4d..820af045 100644 --- a/src/app/pages/account-page/account-page.component.spec.ts +++ b/src/app/pages/account-page/account-page.component.spec.ts @@ -7,6 +7,8 @@ import { RuntimeEnvService } from '../../services/runtime-env.service'; import { createSpyObj } from '../../testing/spy-obj.testing'; describe('AccountPageComponent', () => { + afterEach(() => vi.useRealTimers()); + function render(runtimeEnv: Partial) { const authService = createSpyObj('AuthService', ['logout'], { authState: signal({ status: 'authenticated', user: { fullname: 'Marie Curie', email: 'm@c.eu' } }), @@ -37,7 +39,6 @@ describe('AccountPageComponent', () => { const tokenLink = el.querySelector('.token-btn')!; expect(tokenLink.getAttribute('href')).toBe('/jupyter/hub/token'); expect(tokenLink.target).toBe('_blank'); - expect(el.querySelector('a[href="/notebook"]')).not.toBeNull(); }); it('copies a setup snippet built from the current origin, with no real token', async () => { @@ -53,6 +54,17 @@ describe('AccountPageComponent', () => { expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Copied'); }); + it('clears the copy status so the next copy is announced again', async () => { + vi.useFakeTimers(); + vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + const fixture = render({ notebookEnabled: true, jupyterContextPath: '/notebook' }); + + await fixture.componentInstance.copySetupSnippet(); + expect(fixture.componentInstance.copyStatus()).toBe('Copied'); + vi.advanceTimersByTime(2400); + expect(fixture.componentInstance.copyStatus()).toBe(''); + }); + it('tells the user to copy by hand when the clipboard refuses', async () => { vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied')); vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/src/app/pages/account-page/account-page.component.ts b/src/app/pages/account-page/account-page.component.ts index 4ec590f3..b2485479 100644 --- a/src/app/pages/account-page/account-page.component.ts +++ b/src/app/pages/account-page/account-page.component.ts @@ -30,6 +30,7 @@ export class AccountPageComponent { 'export MIP_API_TOKEN=""', ].join('\n'); readonly copyStatus = signal(''); + private copyStatusTimer?: ReturnType; constructor() { } @@ -44,10 +45,17 @@ export class AccountPageComponent { async copySetupSnippet(): Promise { try { await navigator.clipboard.writeText(this.setupSnippet); - this.copyStatus.set('Copied'); + this.showCopyStatus('Copied'); } catch (err) { console.warn('Failed to copy setup snippet:', err); - this.copyStatus.set('Could not copy, select the text instead'); + this.showCopyStatus('Could not copy, select the text instead'); } } + + // Cleared after a while so the live region changes, and is announced, on every click. + private showCopyStatus(message: string): void { + clearTimeout(this.copyStatusTimer); + this.copyStatus.set(message); + this.copyStatusTimer = setTimeout(() => this.copyStatus.set(''), 2400); + } } From 194cc518f5806d908d18f99131cacfd0d41ebfc2 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Mon, 19 Oct 2026 13:04:26 +0300 Subject: [PATCH 5/5] fix(account): set up remote access with `mip login` instead of exporting the token The setup snippet put the API token in an environment variable, where it lands in the shell history and every program started from that terminal, AI assistants included, can read it. `mip login` asks for it without echoing and saves it to a file readable only by the user. --- docs/llm-wiki/08-auth-and-terms-flow.md | 2 +- src/app/pages/account-page/account-page.component.html | 3 ++- src/app/pages/account-page/account-page.component.spec.ts | 2 +- src/app/pages/account-page/account-page.component.ts | 5 +++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/llm-wiki/08-auth-and-terms-flow.md b/docs/llm-wiki/08-auth-and-terms-flow.md index 25347791..b7b4c3c9 100644 --- a/docs/llm-wiki/08-auth-and-terms-flow.md +++ b/docs/llm-wiki/08-auth-and-terms-flow.md @@ -40,7 +40,7 @@ Platform UI auth and JupyterHub auth are related but separate: 8. Hub sets its own session cookie (SameSite=Lax, Secure by default). Any valid Keycloak user for the Hub client is currently allowed (`allow_all = True`). 9. Before spawn, Hub injects the Keycloak access token into the notebook pod as `MIP_TOKEN`, refreshing via stored refresh tokens when needed. 10. Running notebooks can refresh `MIP_TOKEN` through Hub `/notebook/hub/api/platform-token` via the mip client transport. -11. Remote use (account page, notebook flag only): the user creates a personal Hub API token at `/notebook/hub/token` (a top-level navigation that nginx proxies to Hub, not back into the shell) and sets `MIP_URL`/`MIP_API_TOKEN` for the mip client on their own computer. The UI only links to Hub's page; it never requests, stores or shows the token. +11. Remote use (account page, notebook flag only): the user creates a personal Hub API token at `/notebook/hub/token` (a top-level navigation that nginx proxies to Hub, not back into the shell) sets `MIP_URL` and runs `mip login` (which saves the token to a file) for the mip client on their own computer. The UI only links to Hub's page; it never requests, stores or shows the token. Key files: `src/app/pages/notebook/hub-session.ts`, `hub-http.ts`, `jupyterhub-api.ts`, `nginx.conf.template`. diff --git a/src/app/pages/account-page/account-page.component.html b/src/app/pages/account-page/account-page.component.html index 0605a95d..849aa971 100644 --- a/src/app/pages/account-page/account-page.component.html +++ b/src/app/pages/account-page/account-page.component.html @@ -71,8 +71,9 @@

      Use MIP from your own computer

      {{ copyStatus() }}

      + mip login asks for your token without showing it. In Windows PowerShell, write $env:MIP_URL = "…" instead of - export MIP_URL=…, and the same for MIP_API_TOKEN. + export MIP_URL=….

      Then start Python in the same terminal and run:

      import mip; client = mip.Client.from_env()
      diff --git a/src/app/pages/account-page/account-page.component.spec.ts b/src/app/pages/account-page/account-page.component.spec.ts index 820af045..cb023816 100644 --- a/src/app/pages/account-page/account-page.component.spec.ts +++ b/src/app/pages/account-page/account-page.component.spec.ts @@ -49,7 +49,7 @@ describe('AccountPageComponent', () => { await fixture.whenStable(); expect(writeText).toHaveBeenCalledWith( - `pip install mip-client\nexport MIP_URL=${window.location.origin}\nexport MIP_API_TOKEN=""`, + `pip install mip-client\nexport MIP_URL=${window.location.origin}\nmip login`, ); expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Copied'); }); diff --git a/src/app/pages/account-page/account-page.component.ts b/src/app/pages/account-page/account-page.component.ts index b2485479..1aa3a281 100644 --- a/src/app/pages/account-page/account-page.component.ts +++ b/src/app/pages/account-page/account-page.component.ts @@ -26,8 +26,9 @@ export class AccountPageComponent { readonly setupSnippet = [ 'pip install mip-client', `export MIP_URL=${window.location.origin}`, - // Quoted so the line stays valid shell; the user replaces the text inside the quotes. - 'export MIP_API_TOKEN=""', + // Asks for the token without echoing it and saves it to a file, so it stays out of + // the shell history and of the environment every program (AI assistants too) inherits. + 'mip login', ].join('\n'); readonly copyStatus = signal(''); private copyStatusTimer?: ReturnType;