Skip to content
Draft
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
78 changes: 64 additions & 14 deletions gui-js/apps/minsky-electron/src/app/managers/WindowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ActiveWindow,
AppLayoutPayload,
CreateWindowPayload,
CLERK_PUBLISHABLE_KEY,
events,
Functions,
minsky,
Expand All @@ -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';
Expand Down Expand Up @@ -384,21 +385,70 @@ export class WindowManager {
}
}

static async openLoginWindow() {
const existingToken = StoreManager.store.get('authToken') || '';
const loginWindow = WindowManager.createPopupWindowWithRouting({
width: 420,
height: 500,
title: 'Login',
modal: false,
url: `#/headless/login?authToken=${encodeURIComponent(existingToken)}`,
});

return new Promise<string>((resolve)=>{
// Resolve with null if the user closes the window before authenticating
static async openLoginWindow(): Promise<string | null> {
// 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.
const redirectUrl = 'minsky://signed-in';
const signInUrl = `https://accounts.clerk.com/sign-in?__publishable_key=${encodeURIComponent(CLERK_PUBLISHABLE_KEY)}&redirect_url=${encodeURIComponent(redirectUrl)}`;

return new Promise<string>((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) => {
if (url.startsWith('minsky://')) {
// 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);
});
}

Expand Down
26 changes: 9 additions & 17 deletions gui-js/libs/core/src/lib/services/clerk/clerk.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ export class ClerkService {
async initialize(): Promise<void> {
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;
}

Expand All @@ -31,21 +38,6 @@ export class ClerkService {
return await this.clerk.session.getToken();
}

async signInWithEmailPassword(email: string | null | undefined, password: string | null | undefined): Promise<void> {
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<void> {
if (!this.clerk) throw new Error('Clerk is not initialized.');
await this.clerk.signOut();
Expand All @@ -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');
}
Expand Down
4 changes: 4 additions & 0 deletions gui-js/libs/shared/src/lib/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
39 changes: 10 additions & 29 deletions gui-js/libs/ui-components/src/lib/login/login.component.html
Original file line number Diff line number Diff line change
@@ -1,35 +1,16 @@
<div class="login-container">
<h2>Sign In</h2>

<div *ngIf="isAuthenticated; else loginFormTemplate">
<p class="signed-in-message">You are signed in.</p>
<button mat-raised-button color="warn" (click)="onSignOut()" [disabled]="isLoading">
<mat-spinner *ngIf="isLoading" diameter="20"></mat-spinner>
Sign Out
</button>
<div *ngIf="isLoading" class="loading-spinner">
<mat-spinner diameter="40"></mat-spinner>
</div>

<ng-template #loginFormTemplate>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
<mat-error *ngIf="email?.hasError('required')">Email is required.</mat-error>
<mat-error *ngIf="email?.hasError('email')">Enter a valid email address.</mat-error>
</mat-form-field>

<mat-form-field appearance="outline" class="full-width">
<mat-label>Password</mat-label>
<input matInput type="password" formControlName="password" autocomplete="current-password" />
<mat-error *ngIf="password?.hasError('required')">Password is required.</mat-error>
</mat-form-field>
<ng-container *ngIf="!isLoading">
<div *ngIf="isAuthenticated; else notSignedIn">
<p class="signed-in-message">You are signed in.</p>
<button mat-raised-button color="warn" (click)="onSignOut()">Sign Out</button>
</div>

<ng-template #notSignedIn>
<p *ngIf="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>

<button mat-raised-button color="primary" type="submit" [disabled]="loginForm.invalid || isLoading" class="full-width">
<mat-spinner *ngIf="isLoading" diameter="20"></mat-spinner>
Sign In
</button>
</form>
</ng-template>
</ng-template>
</ng-container>
</div>
17 changes: 3 additions & 14 deletions gui-js/libs/ui-components/src/lib/login/login.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,11 @@
flex-direction: column;
align-items: center;
padding: 24px;
max-width: 400px;
margin: 0 auto;

h2 {
margin-bottom: 16px;
}
}

.full-width {
width: 100%;
margin-bottom: 12px;
.loading-spinner {
margin-top: 40px;
}

.error-message {
Expand All @@ -23,9 +17,4 @@

.signed-in-message {
margin-bottom: 16px;
}

mat-spinner {
display: inline-block;
margin-right: 8px;
}
}
47 changes: 8 additions & 39 deletions gui-js/libs/ui-components/src/lib/login/login.component.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ClerkService } from '@minsky/core';
import { ElectronService } from '@minsky/core';
Expand All @@ -17,20 +14,12 @@ import { take } from 'rxjs';
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatButtonModule,
MatInputModule,
MatFormFieldModule,
MatProgressSpinnerModule,
],
})
export class LoginComponent implements OnInit {
loginForm = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required]),
});

isLoading = false;
isLoading = true;
errorMessage = '';
isAuthenticated = false;

Expand All @@ -49,41 +38,20 @@ export class LoginComponent implements OnInit {
private async initializeSession(authToken: string | undefined) {
try {
await this.clerkService.initialize();
} catch (err) {
this.errorMessage = 'Authentication service failed to load. Please restart the application.';
this.isLoading = false;
return;
}

try {
if (authToken) {
await this.clerkService.setSession(authToken);
}

this.isAuthenticated = await this.clerkService.isSignedIn();
} catch (err) {
this.errorMessage = 'Session expired. Please sign in again.';
this.isAuthenticated = false;
}
}

get email() {
return this.loginForm.get('email');
}

get password() {
return this.loginForm.get('password');
}

async onSubmit() {
if (this.loginForm.invalid) return;

this.isLoading = true;
this.errorMessage = '';

try {
await this.clerkService.signInWithEmailPassword(
this.loginForm.value.email,
this.loginForm.value.password
);
this.isAuthenticated = true;
this.electronService.closeWindow();
} catch (err: any) {
this.errorMessage = err?.errors?.[0]?.message ?? err?.message ?? 'Authentication failed.';
} finally {
this.isLoading = false;
}
Expand All @@ -103,3 +71,4 @@ export class LoginComponent implements OnInit {
this.electronService.closeWindow();
}
}