diff --git a/gui-js/apps/minsky-electron/src/app/managers/ApplicationMenuManager.ts b/gui-js/apps/minsky-electron/src/app/managers/ApplicationMenuManager.ts index 176009361..5fd2c5594 100644 --- a/gui-js/apps/minsky-electron/src/app/managers/ApplicationMenuManager.ts +++ b/gui-js/apps/minsky-electron/src/app/managers/ApplicationMenuManager.ts @@ -132,10 +132,12 @@ export class ApplicationMenuManager { click() {CommandsManager.upgradeUsingClerk();}, }, { - label: 'Manage Clerk Session', - click() {WindowManager.openLoginWindow();}, + label: 'Logout Clerk Session', + click() { + CommandsManager.stashClerkToken(null); + WindowManager.clerkLogout(); + }, }, - { label: 'New System', accelerator: 'CmdOrCtrl + Shift + N', diff --git a/gui-js/apps/minsky-electron/src/app/managers/CommandsManager.ts b/gui-js/apps/minsky-electron/src/app/managers/CommandsManager.ts index 786d9950d..1e74dd25a 100644 --- a/gui-js/apps/minsky-electron/src/app/managers/CommandsManager.ts +++ b/gui-js/apps/minsky-electron/src/app/managers/CommandsManager.ts @@ -1501,6 +1501,7 @@ export class CommandsManager { } static async upgradeUsingClerk(installCase: InstallCase=InstallCase.theLot) { + console.log('authToken:',StoreManager.store.get('authToken')); while (!StoreManager.store.get('authToken')) if (!await WindowManager.openLoginWindow()) return; @@ -1540,4 +1541,12 @@ export class CommandsManager { } + static clerkLogout() { + console.log(WindowManager.clerkApi()); + net.fetch(`https://${WindowManager.clerkApi()}/sign-out`); + CommandsManager.stashClerkToken(null); + } + + + } diff --git a/gui-js/apps/minsky-electron/src/app/managers/WindowManager.ts b/gui-js/apps/minsky-electron/src/app/managers/WindowManager.ts index d880cf920..387f6306e 100644 --- a/gui-js/apps/minsky-electron/src/app/managers/WindowManager.ts +++ b/gui-js/apps/minsky-electron/src/app/managers/WindowManager.ts @@ -2,6 +2,7 @@ import { ActiveWindow, AppLayoutPayload, CreateWindowPayload, + CLERK_PUBLISHABLE_KEY, events, Functions, minsky, @@ -12,7 +13,7 @@ import { Utility, } from '@minsky/shared'; import { StoreManager } from './StoreManager'; -import { BrowserWindow, dialog, Menu, OpenDialogOptions, SaveDialogOptions, screen } from 'electron'; +import { BrowserWindow, dialog, Menu, OpenDialogOptions, SaveDialogOptions, safeStorage, screen } from 'electron'; import log from 'electron-log'; import os from 'os'; import { join, dirname } from 'path'; @@ -384,21 +385,111 @@ export class WindowManager { } } - static async openLoginWindow() { - const existingToken = StoreManager.store.get('authToken') || ''; - const loginWindow = WindowManager.createPopupWindowWithRouting({ - width: 420, - height: 500, - title: 'Login', + // Derive the Clerk frontendApi hostname from the publishable key. + // Key format: pk__ + static clerkApi(): string { +// const encoded = CLERK_PUBLISHABLE_KEY.split('_')[2] ?? ''; +// const padded = encoded + '='.repeat((4 - (encoded.length % 4)) % 4); +// return Buffer.from(padded, 'base64').toString('utf8').replace(/\$$/, ''); + return 'positive-phoenix-85.accounts.dev'; + } + + static clerkLogout() { + const loginWindow = new BrowserWindow({ + width: 480, + height: 640, + title: 'Sign In', + parent: WindowManager.getMainWindow(), modal: false, - url: `#/headless/login?authToken=${encodeURIComponent(existingToken)}`, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + icon: __dirname + '/assets/favicon.png', }); + console.log(`https://${WindowManager.clerkApi()}/sign-out`); + loginWindow.loadURL(`https://${WindowManager.clerkApi()}/sign-out`); + } + + static async openLoginWindow(): Promise { + // Open Clerk's Accounts Portal sign-in page in a dedicated BrowserWindow. + // Passing __publishable_key tells Clerk which app to authenticate against — + // no hostname derivation required. Because this window loads from HTTPS + // (not file://), Clerk's CDN resources and React UI components load + // normally — the full standard Clerk sign-in UI is displayed, including + // every configured OAuth provider. + // + // After successful sign-in, Clerk redirects to redirect_url + // ('minsky://signed-in'). We intercept that navigation with will-navigate, + // execute JS in the still-live sign-in page to obtain a JWT from + // window.Clerk.session.getToken(), stash it, and close the window. + + // This is a dummy URL. It needs to be something like this form for Clerk to redirect back to here + const redirectUrl = '/headless/sign-in'; + let frontendApi: string; + try { + frontendApi = WindowManager.clerkApi(); + } catch { + log.error('WindowManager.openLoginWindow: invalid Clerk publishable key'); + return Promise.resolve(null); + } - return new Promise((resolve)=>{ - // Resolve with null if the user closes the window before authenticating + //https://positive-phoenix-85.accounts.dev + + const signInUrl = `https://${frontendApi}/sign-in?&redirect_url=${encodeURIComponent(redirectUrl)}`; + //const signInUrl = `https://${frontendApi}/sign-in`; + + console.log(signInUrl); + return new Promise((resolve) => { + const loginWindow = new BrowserWindow({ + width: 480, + height: 640, + title: 'Sign In', + parent: WindowManager.getMainWindow(), + modal: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + icon: __dirname + '/assets/favicon.png', + }); + + loginWindow.setMenu(null); + loginWindow.once('ready-to-show', () => loginWindow.show()); + + loginWindow.webContents.on('will-navigate', async (event, url) => { + console.log('will-navigate',url); + /*if (url.startsWith('https://explore'))*/ { + // The sign-in page is about to redirect to our custom scheme, meaning + // sign-in completed successfully. Prevent the navigation (the minsky:// + // scheme is not registered as a real protocol), then extract the JWT + // from window.Clerk.session in the still-live sign-in page context. + event.preventDefault(); + try { + const token: string | null = await loginWindow.webContents.executeJavaScript( + '(async () => { try { return await window.Clerk?.session?.getToken() ?? null; } catch(e) { return null; } })()' + ); + if (token) { + // Inline token stash (mirrors CommandsManager.stashClerkToken). + if (safeStorage.isEncryptionAvailable()) { + const encrypted = safeStorage.encryptString(token); + StoreManager.store.set('authToken', encrypted.toString('latin1')); + } else { + StoreManager.store.set('authToken', token); + } + } + } catch (err) { + log.error('WindowManager.openLoginWindow: failed to retrieve Clerk token', err); + } + loginWindow.close(); + } + }); + loginWindow.once('closed', () => { - resolve(StoreManager.store.get('authToken')); + resolve(StoreManager.store.get('authToken') as string | null ?? null); }); + + loginWindow.loadURL(signInUrl); }); } diff --git a/gui-js/libs/core/src/lib/services/clerk/clerk.service.ts b/gui-js/libs/core/src/lib/services/clerk/clerk.service.ts index f526b1488..e3edae1a8 100644 --- a/gui-js/libs/core/src/lib/services/clerk/clerk.service.ts +++ b/gui-js/libs/core/src/lib/services/clerk/clerk.service.ts @@ -16,8 +16,15 @@ export class ClerkService { async initialize(): Promise { if (this.initialized) return; + // The npm dist build of @clerk/clerk-js is headless: it deliberately omits + // the React-based pre-built UI components (mountSignIn etc.) to keep the + // bundle small. In Electron the login window is Clerk's own hosted sign-in + // page opened by the main process in a dedicated BrowserWindow, so this + // renderer-side Clerk instance is only used for session queries (isSignedIn, + // getToken, setSession, signOut). standardBrowser:false selects the + // lightweight non-cookie path appropriate for Electron's renderer process. this.clerk = new Clerk(AppConfig.clerkPublishableKey); - await this.clerk.load(); + await this.clerk.load({ standardBrowser: false }); this.initialized = true; } @@ -31,21 +38,6 @@ export class ClerkService { return await this.clerk.session.getToken(); } - async signInWithEmailPassword(email: string | null | undefined, password: string | null | undefined): Promise { - if (!this.clerk) throw new Error('Clerk is not initialized.'); - if (!email || !password) throw new Error('Email and password are required.'); - const result = await this.clerk.client.signIn.create({ - identifier: email, - password, - }); - if (result.status === 'complete') { - await this.clerk.setActive({ session: result.createdSessionId }); - await this.sendTokenToElectron(); - } else { - throw new Error('Sign-in was not completed. Additional steps may be required.'); - } - } - async signOut(): Promise { if (!this.clerk) throw new Error('Clerk is not initialized.'); await this.clerk.signOut(); @@ -70,7 +62,7 @@ export class ClerkService { await this.clerk.setActive({ session: this.clerk.client.sessions[0].id }); } if (!this.clerk.session) { - if (this.electronService.isElectron) + if (this.electronService.isElectron) await this.electronService.invoke(events.SET_AUTH_TOKEN, null); throw new Error('Session expired or invalid'); } diff --git a/gui-js/libs/shared/src/lib/constants/constants.ts b/gui-js/libs/shared/src/lib/constants/constants.ts index b72a14ae4..38253e65a 100644 --- a/gui-js/libs/shared/src/lib/constants/constants.ts +++ b/gui-js/libs/shared/src/lib/constants/constants.ts @@ -3,6 +3,10 @@ export const rendererAppURL = `http://localhost:${rendererAppPort}`; export const rendererAppName = 'minsky-web'; export const electronAppName = 'minsky-electron'; export const backgroundColor = '#c1c1c1'; + +// Clerk publishable key — used in both the Angular renderer and the Electron main process. +// The frontendApi hostname is base64-encoded in the third segment of the key. +export const CLERK_PUBLISHABLE_KEY = 'pk_test_cG9zaXRpdmUtcGhvZW5peC04NS5jbGVyay5hY2NvdW50cy5kZXYk'; export const updateServerUrl = 'https://deployment-server-url.com'; // TODO: insert your update server url here export const defaultBackgroundColor = '#ffffff'; diff --git a/gui-js/libs/ui-components/src/lib/login/login.component.html b/gui-js/libs/ui-components/src/lib/login/login.component.html index 04d705eba..d3c8bf3fd 100644 --- a/gui-js/libs/ui-components/src/lib/login/login.component.html +++ b/gui-js/libs/ui-components/src/lib/login/login.component.html @@ -1,35 +1,16 @@