diff --git a/docs/llm-wiki/08-auth-and-terms-flow.md b/docs/llm-wiki/08-auth-and-terms-flow.md index bea4ac16..b7b4c3c9 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) 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.css b/src/app/pages/account-page/account-page.component.css index 50dd97ef..1dbdba37 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: var(--primary-color); + color: #ffffff; + padding: 8px 14px; + border-radius: var(--radius-md); + font-size: 14px; + font-weight: 500; + text-decoration: none; +} + +.token-btn:hover { + background-color: var(--primary-dark); +} + +.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..849aa971 100644 --- a/src/app/pages/account-page/account-page.component.html +++ b/src/app/pages/account-page/account-page.component.html @@ -43,6 +43,44 @@

Profile Information

+ + @if (notebookEnabled) { +
+

Use MIP from your own computer

+
    +
  1. + + + Create API token + +

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

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

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

    +

    Then start Python in the same terminal and run:

    +
    import mip; client = mip.Client.from_env()
    +
  4. +
+
+ } \ 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..cb023816 --- /dev/null +++ b/src/app/pages/account-page/account-page.component.spec.ts @@ -0,0 +1,78 @@ +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', () => { + afterEach(() => vi.useRealTimers()); + + 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'); + }); + + 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}\nmip login`, + ); + 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(() => {}); + 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 ef88192e..1aa3a281 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,25 @@ 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}`, + // 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; + constructor() { } signOut(): void { @@ -26,4 +42,21 @@ export class AccountPageComponent { goBack(): void { this.location.back(); } + + async copySetupSnippet(): Promise { + try { + await navigator.clipboard.writeText(this.setupSnippet); + this.showCopyStatus('Copied'); + } catch (err) { + console.warn('Failed to copy setup snippet:', err); + 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); + } }