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
+
+ -
+
+
+ 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.
+
+
+ -
+ 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()
+
+
+
+ }
\ 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);
+ }
}