Skip to content

Commit 4596f7f

Browse files
fix: use Clerk hosted sign-in page in BrowserWindow (fixes 'not loaded with Ui components')
Agent-Logs-Url: https://github.com/highperformancecoder/minsky/sessions/7da5d79d-7c5f-4968-8943-58398993784d Co-authored-by: highperformancecoder <3075825+highperformancecoder@users.noreply.github.com>
1 parent 9907411 commit 4596f7f

6 files changed

Lines changed: 91 additions & 77 deletions

File tree

gui-js/apps/minsky-electron/src/app/managers/WindowManager.ts

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
ActiveWindow,
33
AppLayoutPayload,
44
CreateWindowPayload,
5+
CLERK_PUBLISHABLE_KEY,
56
events,
67
Functions,
78
minsky,
@@ -12,7 +13,7 @@ import {
1213
Utility,
1314
} from '@minsky/shared';
1415
import { StoreManager } from './StoreManager';
15-
import { BrowserWindow, dialog, Menu, OpenDialogOptions, SaveDialogOptions, screen } from 'electron';
16+
import { BrowserWindow, dialog, Menu, OpenDialogOptions, SaveDialogOptions, safeStorage, screen } from 'electron';
1617
import log from 'electron-log';
1718
import os from 'os';
1819
import { join, dirname } from 'path';
@@ -384,21 +385,82 @@ export class WindowManager {
384385
}
385386
}
386387

387-
static async openLoginWindow() {
388-
const existingToken = StoreManager.store.get('authToken') || '';
389-
const loginWindow = WindowManager.createPopupWindowWithRouting({
390-
width: 420,
391-
height: 500,
392-
title: 'Login',
393-
modal: false,
394-
url: `#/headless/login?authToken=${encodeURIComponent(existingToken)}`,
395-
});
396-
397-
return new Promise<string>((resolve)=>{
398-
// Resolve with null if the user closes the window before authenticating
388+
static async openLoginWindow(): Promise<string | null> {
389+
// Derive the Clerk frontendApi hostname from the publishable key.
390+
// Key format: pk_<type>_<base64(frontendApi + '$')>
391+
const encoded = CLERK_PUBLISHABLE_KEY.split('_')[2] ?? '';
392+
const padded = encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
393+
let frontendApi: string;
394+
try {
395+
frontendApi = Buffer.from(padded, 'base64').toString('utf8').replace(/\$$/, '');
396+
} catch {
397+
log.error('WindowManager.openLoginWindow: invalid Clerk publishable key');
398+
return Promise.resolve(null);
399+
}
400+
401+
// Open Clerk's hosted sign-in page in a dedicated BrowserWindow.
402+
// Because this window loads from HTTPS (not file://), Clerk's CDN
403+
// resources and React UI components load normally — the full standard
404+
// Clerk sign-in UI is displayed, including every configured OAuth provider.
405+
// This mirrors the approach used by @clerk/electron without requiring that
406+
// package.
407+
//
408+
// After successful sign-in, Clerk redirects to redirect_url ('minsky://signed-in').
409+
// We intercept that navigation with will-navigate, execute JS in the still-live
410+
// sign-in page to obtain a JWT from window.Clerk.session.getToken(), stash it,
411+
// and close the window.
412+
const redirectUrl = 'minsky://signed-in';
413+
const signInUrl = `https://${frontendApi}/sign-in?redirect_url=${encodeURIComponent(redirectUrl)}`;
414+
415+
return new Promise<string>((resolve) => {
416+
const loginWindow = new BrowserWindow({
417+
width: 480,
418+
height: 640,
419+
title: 'Sign In',
420+
parent: WindowManager.getMainWindow(),
421+
modal: false,
422+
webPreferences: {
423+
contextIsolation: true,
424+
nodeIntegration: false,
425+
},
426+
icon: __dirname + '/assets/favicon.png',
427+
});
428+
429+
loginWindow.setMenu(null);
430+
loginWindow.once('ready-to-show', () => loginWindow.show());
431+
432+
loginWindow.webContents.on('will-navigate', async (event, url) => {
433+
if (url.startsWith('minsky://')) {
434+
// The sign-in page is about to redirect to our custom scheme, meaning
435+
// sign-in completed successfully. Prevent the navigation (the minsky://
436+
// scheme is not registered as a real protocol), then extract the JWT
437+
// from window.Clerk.session in the still-live sign-in page context.
438+
event.preventDefault();
439+
try {
440+
const token: string | null = await loginWindow.webContents.executeJavaScript(
441+
'(async () => { try { return await window.Clerk?.session?.getToken() ?? null; } catch(e) { return null; } })()'
442+
);
443+
if (token) {
444+
// Inline token stash (mirrors CommandsManager.stashClerkToken).
445+
if (safeStorage.isEncryptionAvailable()) {
446+
const encrypted = safeStorage.encryptString(token);
447+
StoreManager.store.set('authToken', encrypted.toString('latin1'));
448+
} else {
449+
StoreManager.store.set('authToken', token);
450+
}
451+
}
452+
} catch (err) {
453+
log.error('WindowManager.openLoginWindow: failed to retrieve Clerk token', err);
454+
}
455+
loginWindow.close();
456+
}
457+
});
458+
399459
loginWindow.once('closed', () => {
400-
resolve(StoreManager.store.get('authToken'));
460+
resolve(StoreManager.store.get('authToken') as string | null ?? null);
401461
});
462+
463+
loginWindow.loadURL(signInUrl);
402464
});
403465
}
404466

gui-js/libs/core/src/lib/services/clerk/clerk.service.ts

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ export class ClerkService {
1616
async initialize(): Promise<void> {
1717
if (this.initialized) return;
1818

19-
// Use the headless Clerk JS package (no React/ClerkUI dependency).
20-
// Pre-built UI components (mountSignIn etc.) require @clerk/ui which loads
21-
// React chunks lazily from Clerk's CDN. Electron blocks those requests, so
22-
// mountSignIn() always throws "Clerk was not loaded with Ui components".
23-
// Authentication is performed directly via clerk.client.signIn.create().
24-
// standardBrowser: false uses the lightweight non-cookie path appropriate
25-
// for Electron's renderer process.
19+
// The npm dist build of @clerk/clerk-js is headless: it deliberately omits
20+
// the React-based pre-built UI components (mountSignIn etc.) to keep the
21+
// bundle small. In Electron the login window is Clerk's own hosted sign-in
22+
// page opened by the main process in a dedicated BrowserWindow, so this
23+
// renderer-side Clerk instance is only used for session queries (isSignedIn,
24+
// getToken, setSession, signOut). standardBrowser:false selects the
25+
// lightweight non-cookie path appropriate for Electron's renderer process.
2626
this.clerk = new Clerk(AppConfig.clerkPublishableKey);
2727
await this.clerk.load({ standardBrowser: false });
2828
this.initialized = true;
@@ -38,21 +38,6 @@ export class ClerkService {
3838
return await this.clerk.session.getToken();
3939
}
4040

41-
async signInWithEmailPassword(email: string | null | undefined, password: string | null | undefined): Promise<void> {
42-
if (!this.clerk) throw new Error('Clerk is not initialized.');
43-
if (!email || !password) throw new Error('Email and password are required.');
44-
const result = await this.clerk.client.signIn.create({
45-
identifier: email,
46-
password,
47-
});
48-
if (result.status === 'complete') {
49-
await this.clerk.setActive({ session: result.createdSessionId });
50-
await this.sendTokenToElectron();
51-
} else {
52-
throw new Error('Sign-in was not completed. Additional steps may be required.');
53-
}
54-
}
55-
5641
async signOut(): Promise<void> {
5742
if (!this.clerk) throw new Error('Clerk is not initialized.');
5843
await this.clerk.signOut();
@@ -77,7 +62,7 @@ export class ClerkService {
7762
await this.clerk.setActive({ session: this.clerk.client.sessions[0].id });
7863
}
7964
if (!this.clerk.session) {
80-
if (this.electronService.isElectron)
65+
if (this.electronService.isElectron)
8166
await this.electronService.invoke(events.SET_AUTH_TOKEN, null);
8267
throw new Error('Session expired or invalid');
8368
}

gui-js/libs/shared/src/lib/constants/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ export const rendererAppURL = `http://localhost:${rendererAppPort}`;
33
export const rendererAppName = 'minsky-web';
44
export const electronAppName = 'minsky-electron';
55
export const backgroundColor = '#c1c1c1';
6+
7+
// Clerk publishable key — used in both the Angular renderer and the Electron main process.
8+
// The frontendApi hostname is base64-encoded in the third segment of the key.
9+
export const CLERK_PUBLISHABLE_KEY = 'pk_test_cG9zaXRpdmUtcGhvZW5peC04NS5jbGVyay5hY2NvdW50cy5kZXYk';
610
export const updateServerUrl = 'https://deployment-server-url.com'; // TODO: insert your update server url here
711

812
export const defaultBackgroundColor = '#ffffff';

gui-js/libs/ui-components/src/lib/login/login.component.html

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,13 @@
44
</div>
55

66
<ng-container *ngIf="!isLoading">
7-
<div *ngIf="isAuthenticated; else signInTemplate">
7+
<div *ngIf="isAuthenticated; else notSignedIn">
88
<p class="signed-in-message">You are signed in.</p>
99
<button mat-raised-button color="warn" (click)="onSignOut()">Sign Out</button>
1010
</div>
1111

12-
<ng-template #signInTemplate>
12+
<ng-template #notSignedIn>
1313
<p *ngIf="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
14-
<form class="sign-in-form" (ngSubmit)="onSignIn()" #signInForm="ngForm" novalidate>
15-
<mat-form-field appearance="outline">
16-
<mat-label>Email</mat-label>
17-
<input matInput type="email" [(ngModel)]="email" name="email" autocomplete="email" required>
18-
</mat-form-field>
19-
<mat-form-field appearance="outline">
20-
<mat-label>Password</mat-label>
21-
<input matInput type="password" [(ngModel)]="password" name="password" autocomplete="current-password" required>
22-
</mat-form-field>
23-
<button mat-raised-button color="primary" type="submit" [disabled]="isSigningIn || !email || !password">
24-
<mat-spinner *ngIf="isSigningIn" diameter="20" class="button-spinner"></mat-spinner>
25-
{{ isSigningIn ? 'Signing in…' : 'Sign In' }}
26-
</button>
27-
</form>
2814
</ng-template>
2915
</ng-container>
3016
</div>

gui-js/libs/ui-components/src/lib/login/login.component.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@
1717

1818
.signed-in-message {
1919
margin-bottom: 16px;
20-
}
20+
}

gui-js/libs/ui-components/src/lib/login/login.component.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { Component, OnInit } from '@angular/core';
22
import { CommonModule } from '@angular/common';
3-
import { FormsModule } from '@angular/forms';
43
import { MatButtonModule } from '@angular/material/button';
5-
import { MatFormFieldModule } from '@angular/material/form-field';
6-
import { MatInputModule } from '@angular/material/input';
74
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
85
import { ClerkService } from '@minsky/core';
96
import { ElectronService } from '@minsky/core';
@@ -17,20 +14,14 @@ import { take } from 'rxjs';
1714
standalone: true,
1815
imports: [
1916
CommonModule,
20-
FormsModule,
2117
MatButtonModule,
22-
MatFormFieldModule,
23-
MatInputModule,
2418
MatProgressSpinnerModule,
2519
],
2620
})
2721
export class LoginComponent implements OnInit {
2822
isLoading = true;
29-
isSigningIn = false;
3023
errorMessage = '';
3124
isAuthenticated = false;
32-
email = '';
33-
password = '';
3425

3526
constructor(
3627
private clerkService: ClerkService,
@@ -66,20 +57,6 @@ export class LoginComponent implements OnInit {
6657
}
6758
}
6859

69-
async onSignIn() {
70-
this.isSigningIn = true;
71-
this.errorMessage = '';
72-
try {
73-
await this.clerkService.signInWithEmailPassword(this.email, this.password);
74-
this.isAuthenticated = true;
75-
this.electronService.closeWindow();
76-
} catch (err: any) {
77-
this.errorMessage = err?.message ?? 'Sign in failed.';
78-
} finally {
79-
this.isSigningIn = false;
80-
}
81-
}
82-
8360
async onSignOut() {
8461
this.isLoading = true;
8562
this.errorMessage = '';

0 commit comments

Comments
 (0)