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
1 change: 1 addition & 0 deletions docs/llm-wiki/08-auth-and-terms-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
86 changes: 86 additions & 0 deletions src/app/pages/account-page/account-page.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
38 changes: 38 additions & 0 deletions src/app/pages/account-page/account-page.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,44 @@ <h3>Profile Information</h3>
</div>
</div>
</section>

@if (notebookEnabled) {
<section class="info-section remote-access">
<h3>Use MIP from your own computer</h3>
<ol class="remote-steps">
<li>
<a class="token-btn" [href]="hubTokenUrl" target="_blank" rel="noopener"
aria-label="Create API token (opens in a new tab)">
<i class="fas fa-key"></i>
<span>Create API token</span>
</a>
<p>
In <strong>Permissions</strong>, enter <code>custom:mip-platform-token</code> and choose an expiry.
The token is shown once. You can revoke it on the same page.
</p>
</li>
<li>
In a terminal on your computer, run:
<div class="snippet">
<pre><code>{{ setupSnippet }}</code></pre>
<button class="copy-btn" type="button" (click)="copySetupSnippet()"
aria-label="Copy setup commands">
<i class="fas fa-copy"></i>
<span>Copy</span>
</button>
</div>
<span class="copy-status" aria-live="polite">{{ copyStatus() }}</span>
<p>
<code>mip login</code> asks for your token without showing it.
In Windows PowerShell, write <code>$env:MIP_URL = "…"</code> instead of
<code>export MIP_URL=…</code>.
</p>
<p>Then start Python in the same terminal and run:</p>
<pre><code>import mip; client = mip.Client.from_env()</code></pre>
</li>
</ol>
</section>
}
</section>
</div>
</div>
78 changes: 78 additions & 0 deletions src/app/pages/account-page/account-page.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<RuntimeEnvService>) {
const authService = createSpyObj<AuthService>('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<HTMLAnchorElement>('.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');
});
});
35 changes: 34 additions & 1 deletion src/app/pages/account-page/account-page.component.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<typeof setTimeout>;

constructor() { }

signOut(): void {
Expand All @@ -26,4 +42,21 @@ export class AccountPageComponent {
goBack(): void {
this.location.back();
}

async copySetupSnippet(): Promise<void> {
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);
}
}
Loading