diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cb3b4848..a58cf049 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,7 @@ on: branches: - main - release/dev + - feature/authentication env: # This will be set by the first job and used by all others diff --git a/assets/index.ts b/assets/index.ts index b9c156e9..6aa16c64 100644 --- a/assets/index.ts +++ b/assets/index.ts @@ -1,4 +1,5 @@ import logo from './logo.svg'; import { icons } from './icons'; +import rosettaIcon from './icon.png'; -export { logo, icons }; +export { logo, icons, rosettaIcon }; diff --git a/package-lock.json b/package-lock.json index 1defddf3..af86c992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rosetta-dbt-studio", - "version": "1.3.0", + "version": "1.3.1-auth-internal", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rosetta-dbt-studio", - "version": "1.3.0", + "version": "1.3.1-auth-internal", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 27ea2ba0..999fc23c 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.3.0", + "version": "1.3.1-auth-internal", "name": "rosetta-dbt-studio", "description": "Turn Raw Data into Business Insights—Faster with RosettaDB", "keywords": [ diff --git a/release/app/package-lock.json b/release/app/package-lock.json index dea9e44b..a2c70aae 100644 --- a/release/app/package-lock.json +++ b/release/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "rosetta-dbt-studio", - "version": "1.3.0", + "version": "1.3.1-auth-internal", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "rosetta-dbt-studio", - "version": "1.3.0", + "version": "1.3.1-auth-internal", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/release/app/package.json b/release/app/package.json index 1a4de5bc..a5626ca8 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,6 +1,6 @@ { "name": "rosetta-dbt-studio", - "version": "1.3.0", + "version": "1.3.1-auth-internal", "description": "A modern DBT desktop IDE", "license": "MIT", "author": { diff --git a/src/main/ipcHandlers/git.ipcHandlers.ts b/src/main/ipcHandlers/git.ipcHandlers.ts index 768b7d83..07f3daa9 100644 --- a/src/main/ipcHandlers/git.ipcHandlers.ts +++ b/src/main/ipcHandlers/git.ipcHandlers.ts @@ -1,7 +1,12 @@ import { ipcMain } from 'electron'; import { GitService } from '../services'; import { AuthError } from '../errors'; -import { FileStatus, GitCredentials } from '../../types/backend'; +import { + FileStatus, + GitChangesRes, + GitCredentials, + RepoInfoRes, +} from '../../types/backend'; const gitService = new GitService(); @@ -206,6 +211,26 @@ const registerGitHandlers = () => { }, ); + ipcMain.handle( + 'git:getLocalChanges', + async ( + _event, + { repoPath }: { repoPath: string }, + ): Promise => { + return gitService.getLocalChangesStatus(repoPath); + }, + ); + + ipcMain.handle( + 'git:repoInfo', + async ( + _event, + { repoPath }: { repoPath: string }, + ): Promise => { + return gitService.getRepoInfo(repoPath); + }, + ); + ipcMain.handle( 'git:unstage', async ( diff --git a/src/main/ipcHandlers/index.ts b/src/main/ipcHandlers/index.ts index 93d13ba1..975f4fd8 100644 --- a/src/main/ipcHandlers/index.ts +++ b/src/main/ipcHandlers/index.ts @@ -9,6 +9,7 @@ import registerSecureStorageHandlers from './secureStorage.ipcHandlers'; import registerUpdateHandlers from './updates.ipcHandlers'; import registerCloudExplorerHandlers from './cloudExplorer.ipcHandlers'; import registerAIHandlers from './ai.ipcHandlers'; +import registerRosettaCloudIpcHandlers from './rosettaCloud.ipcHandlers'; import registerDuckLakeHandlers from './duckLake.ipcHandlers'; import registerLineageHandlers from './lineage.ipcHandlers'; @@ -24,6 +25,7 @@ export { registerUpdateHandlers, registerCloudExplorerHandlers, registerAIHandlers, + registerRosettaCloudIpcHandlers, registerDuckLakeHandlers, registerLineageHandlers, }; diff --git a/src/main/ipcHandlers/projects.ipcHandlers.ts b/src/main/ipcHandlers/projects.ipcHandlers.ts index 8ad564ce..ba915f8c 100644 --- a/src/main/ipcHandlers/projects.ipcHandlers.ts +++ b/src/main/ipcHandlers/projects.ipcHandlers.ts @@ -207,23 +207,6 @@ const registerProjectHandlers = () => { return ProjectsService.downloadSeed(body); }, ); - - ipcMain.handle( - 'project:pushToCloud', - async ( - _event, - body: { - title: string; - gitUrl: string; - gitBranch: string; - apiKey: string; - githubUsername?: string; - githubPassword?: string; - }, - ) => { - return ProjectsService.pushProjectToCloud(body); - }, - ); }; export default registerProjectHandlers; diff --git a/src/main/ipcHandlers/rosettaCloud.ipcHandlers.ts b/src/main/ipcHandlers/rosettaCloud.ipcHandlers.ts new file mode 100644 index 00000000..7ad7572e --- /dev/null +++ b/src/main/ipcHandlers/rosettaCloud.ipcHandlers.ts @@ -0,0 +1,63 @@ +import { ipcMain } from 'electron'; +import { RosettaCloudService } from '../services'; +import { CloudDeploymentPayload } from '../../types/backend'; + +const registerRosettaCloudIpcHandlers = () => { + ipcMain.handle( + 'rosettaCloud:push', + async (_event, body: CloudDeploymentPayload) => { + return RosettaCloudService.pushProjectToCloud(body); + }, + ); + + ipcMain.handle('rosettaCloud:getProfile', async () => { + return RosettaCloudService.getProfile(); + }); + + ipcMain.handle('rosettaCloud:refreshProfile', async () => { + return RosettaCloudService.refreshProfile(); + }); + + ipcMain.handle('rosettaCloud:getCachedProfile', async () => { + return RosettaCloudService.getCachedProfile(); + }); + + ipcMain.handle('rosettaCloud:login', async () => { + return RosettaCloudService.openLogin(); + }); + + ipcMain.handle('rosettaCloud:getApiKey', async () => { + return RosettaCloudService.getApiKey(); + }); + + ipcMain.handle('rosettaCloud:logout', async () => { + await RosettaCloudService.clearApiKey(); + }); + + ipcMain.handle('rosettaCloud:storeApiKey', async (_event, apiKey: string) => { + await RosettaCloudService.storeApiKey(apiKey); + }); + + ipcMain.handle( + 'rosettaCloud:validateApiKey', + async (_event, apiKey: string) => { + return RosettaCloudService.validateApiKey(apiKey); + }, + ); + + ipcMain.handle( + 'rosettaCloud:getSecrets', + async (_event, projectId: string) => { + return RosettaCloudService.getSecrets(projectId); + }, + ); + + ipcMain.handle( + 'rosettaCloud:deleteSecret', + async (_event, projectId: string, secretId: string) => { + return RosettaCloudService.deleteSecret(projectId, secretId); + }, + ); +}; + +export default registerRosettaCloudIpcHandlers; diff --git a/src/main/ipcSetup.ts b/src/main/ipcSetup.ts index 09ab77f7..7e3bc24d 100644 --- a/src/main/ipcSetup.ts +++ b/src/main/ipcSetup.ts @@ -11,6 +11,7 @@ import { registerUpdateHandlers, registerCloudExplorerHandlers, registerAIHandlers, + registerRosettaCloudIpcHandlers, registerDuckLakeHandlers, registerLineageHandlers, } from './ipcHandlers'; @@ -27,6 +28,7 @@ const registerHandlers = (mainWindow: BrowserWindow) => { registerUpdateHandlers(); registerCloudExplorerHandlers(); registerAIHandlers(); + registerRosettaCloudIpcHandlers(); registerDuckLakeHandlers(); registerLineageHandlers(); }; diff --git a/src/main/main.ts b/src/main/main.ts index 8b54333e..b4aabdeb 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -10,6 +10,7 @@ import { SettingsService, AnalyticsService, UpdateService, + RosettaCloudService, DuckLakeConnectionManager, } from './services'; import { copyAssetsToUserData } from './utils/fileHelper'; @@ -31,13 +32,85 @@ protocol.registerSchemesAsPrivileged([ bypassCSP: true, }, }, + { + scheme: 'rosetta', + privileges: { + standard: true, + secure: true, + }, + }, ]); setupApplicationIcon(); +let windowManager: WindowManager | null = null; +async function handleDeepLink(url: string) { + try { + const parsedUrl = new URL(url); + if ( + parsedUrl.protocol === 'rosetta:' && + (parsedUrl.pathname === '//auth' || parsedUrl.host === 'auth') + ) { + const apiKey = parsedUrl.searchParams.get('token'); // Still called 'token' in URL for compatibility + if (apiKey) { + try { + await RosettaCloudService.storeApiKey(apiKey); + + windowManager + ?.getMainWindow() + ?.webContents.send('rosettaCloud:apiKeyUpdated'); + + windowManager + ?.getMainWindow() + ?.webContents.send('rosettaCloud:authSuccess', { + apiKey, + }); + + return; + } catch (storageError) { + console.error( + 'Failed to store API key from deep link:', + storageError, + ); + windowManager + ?.getMainWindow() + ?.webContents.send('rosettaCloud:authError', { + error: 'Failed to store API key. Please try again.', + }); + return; + } + } + + windowManager + ?.getMainWindow() + ?.webContents.send('rosettaCloud:authError', { + error: 'Missing API key in deep link response.', + }); + } + } catch (error) { + console.error('Deep link processing error:', error); + windowManager?.getMainWindow()?.webContents.send('rosettaCloud:authError', { + error: + error instanceof Error + ? `Failed to process deep link: ${error.message}` + : 'Failed to process deep link.', + }); + } +} + // Ensure single instance of the app const gotTheLock = app.requestSingleInstanceLock(); -let windowManager: WindowManager | null = null; + +// Register custom protocol for deep linking +if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient('rosetta', process.execPath, [ + process.argv[1], + ]); + } +} else { + app.setAsDefaultProtocolClient('rosetta'); +} if (!gotTheLock) { console.log('Another instance is already running. Quitting...'); @@ -150,9 +223,15 @@ if (!gotTheLock) { }) .catch(console.log); - app.on('second-instance', () => { + app.on('second-instance', (event, commandLine) => { if (!windowManager) return; + // Handle deep link from second instance + const url = commandLine.find((arg) => arg.startsWith('rosetta://')); + if (url) { + handleDeepLink(url); + } + const activeWindow = windowManager.getMainWindow(); if (activeWindow) { @@ -163,6 +242,21 @@ if (!gotTheLock) { windowManager.startApplication(); } }); + + // Handle deep links on macOS + app.on('open-url', (event, url) => { + event.preventDefault(); + handleDeepLink(url); + }); + + // Handle deep links on Windows/Linux + app.on('ready', () => { + // Check if app was opened with a deep link + const url = process.argv.find((arg) => arg.startsWith('rosetta://')); + if (url) { + handleDeepLink(url); + } + }); } ipcMain.handle('windows:closeSetup', () => { diff --git a/src/main/services/git.service.ts b/src/main/services/git.service.ts index 6cbc9f8d..492432cf 100644 --- a/src/main/services/git.service.ts +++ b/src/main/services/git.service.ts @@ -3,7 +3,12 @@ import simpleGit, { SimpleGit } from 'simple-git'; import path from 'path'; import fs from 'fs'; import { AuthError } from '../errors'; -import { FileStatus, GitCredentials } from '../../types/backend'; +import { + FileStatus, + GitChangesRes, + GitCredentials, + RepoInfoRes, +} from '../../types/backend'; import SettingsService from './settings.service'; import ConnectorsService from './connectors.service'; @@ -988,6 +993,212 @@ export default class GitService { return null; } + /** + * Check if there are any untracked files in the repository + */ + async hasUntrackedChanges(repoPath: string): Promise { + try { + const git = this.getGitInstance(repoPath); + const status = await git.status(); + return status.not_added.length > 0; + } catch (err: any) { + throw new Error(`Failed to check untracked changes: ${err.message}`); + } + } + + /** + * Check if there are any uncommitted changes (modified, deleted, or staged files) + */ + async hasUncommittedChanges(repoPath: string): Promise { + try { + const git = this.getGitInstance(repoPath); + const status = await git.status(); + + return ( + status.modified.length > 0 || + status.deleted.length > 0 || + status.staged.length > 0 || + status.renamed.length > 0 || + status.conflicted.length > 0 + ); + } catch (err: any) { + throw new Error(`Failed to check uncommitted changes: ${err.message}`); + } + } + + /** + * Check if there are any unpushed commits on the current branch + */ + async hasUnpushedChanges(repoPath: string): Promise { + try { + const git = this.getGitInstance(repoPath); + + // Get current branch + const branchSummary = await git.branch(); + const currentBranch = branchSummary.current; + + if (!currentBranch) { + return false; + } + + // Fetch to get latest remote info (without merging) + try { + await git.fetch(); + } catch (err) { + return false; + } + + // Check if remote branch exists + const remoteBranches = await git.branch(['-r']); + const hasRemoteBranch = remoteBranches.all.includes( + `origin/${currentBranch}`, + ); + + if (!hasRemoteBranch) { + // If there's no remote branch, check if there are any commits + const log = await git.log(); + return log.total > 0; + } + + // Compare local and remote + const result = await git.raw([ + 'rev-list', + '--count', + `origin/${currentBranch}..HEAD`, + ]); + + const unpushedCount = parseInt(result.trim(), 10); + return unpushedCount > 0; + } catch (err: any) { + throw new Error(`Failed to check unpushed changes: ${err.message}`); + } + } + + /** + * Check if there are any local changes (untracked, uncommitted, or unpushed) + */ + async hasLocalChanges(repoPath: string): Promise { + try { + const [hasUntracked, hasUncommitted, hasUnpushed] = await Promise.all([ + this.hasUntrackedChanges(repoPath), + this.hasUncommittedChanges(repoPath), + this.hasUnpushedChanges(repoPath), + ]); + + return hasUntracked || hasUncommitted || hasUnpushed; + } catch (err: any) { + throw new Error(`Failed to check local changes: ${err.message}`); + } + } + + /** + * Get detailed information about local changes + */ + async getLocalChangesStatus(repoPath: string): Promise { + try { + const git = this.getGitInstance(repoPath); + const status = await git.status(); + + const hasUntracked = status.not_added.length > 0; + const hasUncommitted = + status.modified.length > 0 || + status.deleted.length > 0 || + status.staged.length > 0 || + status.renamed.length > 0 || + status.conflicted.length > 0; + + const uncommittedCount = + status.modified.length + + status.deleted.length + + status.staged.length + + status.renamed.length + + status.conflicted.length; + + let hasUnpushed = false; + let unpushedCount = 0; + + try { + const branchSummary = await git.branch(); + const currentBranch = branchSummary.current; + + if (currentBranch) { + await git.fetch(); + const remoteBranches = await git.branch(['-r']); + const hasRemoteBranch = remoteBranches.all.includes( + `origin/${currentBranch}`, + ); + + if (hasRemoteBranch) { + const result = await git.raw([ + 'rev-list', + '--count', + `origin/${currentBranch}..HEAD`, + ]); + unpushedCount = parseInt(result.trim(), 10); + hasUnpushed = unpushedCount > 0; + } else { + const log = await git.log(); + unpushedCount = log.total; + hasUnpushed = log.total > 0; + } + } + } catch (err) { + // If we can't determine unpushed status, just return false + hasUnpushed = false; + unpushedCount = 0; + } + + return { + hasUntracked, + hasUncommitted, + hasUnpushed, + untrackedCount: status.not_added.length, + uncommittedCount, + unpushedCount, + }; + } catch (err: any) { + return null; + } + } + + async getRepoInfo(repoPath: string): Promise { + const git = this.getGitInstance(repoPath); + + try { + const remotes = await git.getRemotes(true); + const origin = remotes.find((r) => r.name === 'origin'); + let remoteUrl = origin?.refs?.fetch || null; + + if (remoteUrl && !remoteUrl.endsWith('.git')) { + remoteUrl = `${remoteUrl}.git`; + } + + const branchSummary = await git.branch(); + const currentBranch = branchSummary.current; + + let branchExistsOnRemote = false; + if (currentBranch) { + try { + await git.fetch(); + const remoteBranches = await git.branch(['-r']); + branchExistsOnRemote = remoteBranches.all.includes( + `origin/${currentBranch}`, + ); + } catch (err) { + branchExistsOnRemote = false; + } + } + + return { + remoteUrl, + currentBranch, + branchExistsOnRemote, + }; + } catch (err: any) { + return null; + } + } + async getAheadBehindCount( repoPath: string, ): Promise<{ ahead: number; behind: number } | null> { diff --git a/src/main/services/index.ts b/src/main/services/index.ts index eca91e6d..d0ad0709 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -9,6 +9,7 @@ import CloudExplorerService from './cloudExplorer.service'; import CloudPreviewService from './cloudPreview.service'; import UtilsService from './utilsService'; import SelectedFileContextProvider from './selectedFileContextProvider.service'; +import RosettaCloudService from './rosettaCloud.service'; import DuckLakeService from './duckLake.service'; import DuckLakeInstanceStore from './duckLake/instanceStore.service'; import DuckLakeValidationService from './duckLake/validation.service'; @@ -29,6 +30,7 @@ export { CloudPreviewService, UtilsService, SelectedFileContextProvider, + RosettaCloudService, DuckLakeService, DuckLakeInstanceStore, DuckLakeValidationService, diff --git a/src/main/services/projects.service.ts b/src/main/services/projects.service.ts index 5d448479..5df24d48 100644 --- a/src/main/services/projects.service.ts +++ b/src/main/services/projects.service.ts @@ -8,7 +8,6 @@ import AdmZip from 'adm-zip'; import * as tar from 'tar'; import { BigQueryConnection, - CloudDeploymentPayload, DatabricksConnection, DuckDBConnection, KineticaConnection, @@ -31,7 +30,6 @@ import { saveFileContent, updateDatabase, } from '../utils/fileHelper'; -import { ROSETTA_CLOUD_BASE_URL } from '../utils/constants'; import SettingsService from './settings.service'; import { BigQueryExtractor, @@ -102,84 +100,6 @@ export default class ProjectsService { } } - static async pushProjectToCloud(body: CloudDeploymentPayload): Promise { - const settings = await SettingsService.loadSettings(); - const rosettaCloudUrl = - settings.cloudWorkspaceUrl ?? ROSETTA_CLOUD_BASE_URL; - const baseUrl = rosettaCloudUrl.replace(/\/$/, ''); - const createEndpoint = `${baseUrl}/api/projects`; - - if (!body.apiKey) { - throw new Error('Cloud API key is required to deploy.'); - } - - const requestBody = { - title: body.title, - git_url: body.gitUrl, - git_branch: body.gitBranch, - }; - - const postJson = (url: string, data?: object): Promise => { - return new Promise((resolve, reject) => { - const request = net.request({ - method: 'POST', - url, - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: `Bearer ${body.apiKey}`, - }, - }); - - const chunks: Buffer[] = []; - - request.on('response', (response: IncomingMessage) => { - response.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - - response.on('end', () => { - const raw = Buffer.concat(chunks).toString('utf8'); - let parsed: any; - try { - parsed = raw ? JSON.parse(raw) : {}; - } catch { - parsed = { message: raw }; - } - - if ( - response.statusCode && - response.statusCode >= 200 && - response.statusCode < 300 - ) { - resolve(parsed); - } else { - reject( - new Error( - parsed?.message || - `Rosetta Cloud responded with status ${response.statusCode ?? 'unknown'}.`, - ), - ); - } - }); - }); - - request.on('error', (err) => reject(err)); - - if (data) { - request.write(JSON.stringify(data)); - } - - request.end(); - }); - }; - - const projectData = await postJson(createEndpoint, requestBody); - - const runEndpoint = `${baseUrl}/api/projects/${projectData.id}/run`; - await postJson(runEndpoint); - } - static async saveProjects(projects: Project[]) { // Patch: For all projects, if the connection is bigquery and keyfile is a JSON string, store only the key name for (const project of projects) { diff --git a/src/main/services/rosettaCloud.service.ts b/src/main/services/rosettaCloud.service.ts new file mode 100644 index 00000000..b4bac95e --- /dev/null +++ b/src/main/services/rosettaCloud.service.ts @@ -0,0 +1,303 @@ +/* eslint-disable no-restricted-syntax, no-await-in-loop */ +import { shell } from 'electron'; +import { v4 as uuidv4 } from 'uuid'; +import { CloudDeploymentPayload, Secret } from '../../types/backend'; +import { UserProfile } from '../../types/profile'; + +import { ROSETTA_CLOUD_BASE_URL } from '../utils/constants'; +import SecureStorageService from './secureStorage.service'; +import ProjectsService from './projects.service'; + +export default class RosettaCloudService { + private static cachedProfile: UserProfile | null = null; + + private static readonly API_KEY_STORAGE_KEY = 'cloud-api-key'; + + static async pushProjectToCloud(body: CloudDeploymentPayload): Promise { + const { id, secrets } = body; + const project = await ProjectsService.getProject(id); + const hasSecrets = Object.keys(secrets ?? {}).length > 0; + + if (!project) { + throw new Error('Project not found'); + } + + const rosettaCloudUrl = ROSETTA_CLOUD_BASE_URL; + const baseUrl = rosettaCloudUrl.replace(/\/$/, ''); + + const postJson = async (url: string, data?: object): Promise => { + const apiKey = await this.getApiKey(); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: data ? JSON.stringify(data) : undefined, + }); + + return response.json(); + }; + + const addSecrets = async ( + projectId: string, + secretsArg: Record, + ) => { + const addSecretsEndpoint = `${baseUrl}/api/projects/${projectId}/secrets`; + const addSecretsBody = Object.entries(secretsArg).map(([name, value]) => { + return { + name, + value, + }; + }); + await postJson(addSecretsEndpoint, addSecretsBody); + }; + + if (project.externalId) { + if (hasSecrets) await addSecrets(project.externalId, secrets); + const runEndpoint = `${baseUrl}/api/projects/${project.externalId}/run`; + await postJson(runEndpoint, body); + await ProjectsService.updateProject({ + ...project, + lastRun: new Date().toISOString(), + }); + return; + } + + const createEndpoint = `${baseUrl}/api/projects`; + + const requestBody = { + title: body.title, + git_url: body.gitUrl, + git_branch: body.gitBranch, + }; + + const projectData = await postJson(createEndpoint, requestBody); + await ProjectsService.updateProject({ + ...project, + externalId: projectData.id, + lastRun: new Date().toISOString(), + }); + + if (hasSecrets) await addSecrets(projectData.id, secrets); + + const runEndpoint = `${baseUrl}/api/projects/${projectData.id}/run`; + await postJson(runEndpoint, { + CUSTOM_DBT_COMMANDS: body.CUSTOM_DBT_COMMANDS, + }); + } + + static async getSecrets(projectId: string): Promise { + const project = await ProjectsService.getProject(projectId); + if (!project) { + throw new Error('Project not found'); + } + + if (!project.externalId) { + throw new Error('Project has not been deployed to cloud'); + } + + const rosettaCloudUrl = ROSETTA_CLOUD_BASE_URL; + const baseUrl = rosettaCloudUrl.replace(/\/$/, ''); + + const apiKey = await this.getApiKey(); + const secretsEndpoint = `${baseUrl}/api/projects/${project.externalId}/secrets`; + + const response = await fetch(secretsEndpoint, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch secrets: ${response.status}`); + } + + return response.json(); + } + + static async deleteSecret( + projectId: string, + secretId: string, + ): Promise { + const project = await ProjectsService.getProject(projectId); + + if (!project) { + throw new Error('Project not found'); + } + + if (!project.externalId) { + throw new Error('Project has not been deployed to cloud'); + } + + const rosettaCloudUrl = ROSETTA_CLOUD_BASE_URL; + const baseUrl = rosettaCloudUrl.replace(/\/$/, ''); + + const apiKey = await this.getApiKey(); + const deleteEndpoint = `${baseUrl}/api/projects/${project.externalId}/secrets?secretId=${secretId}`; + + const response = await fetch(deleteEndpoint, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to delete secret: ${response.status}`); + } + } + + static async getProfile(): Promise { + try { + const apiKey = await this.getApiKey(); + + if (!apiKey) { + // eslint-disable-next-line no-console + console.log('No API key available for profile fetch'); + return null; + } + + const response = await fetch( + `${ROSETTA_CLOUD_BASE_URL}/api/electron/profile`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + if (response.status === 401) { + // API key invalid, clear it + await this.clearApiKey(); + this.cachedProfile = null; + return null; + } + throw new Error(`Profile fetch failed: ${response.status}`); + } + + const data = await response.json(); + this.cachedProfile = data.profile; + return data.profile; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Profile service error:', error); + return this.cachedProfile; // Return cached data on network error + } + } + + static async refreshProfile(): Promise { + this.cachedProfile = null; // Clear cache + return this.getProfile(); + } + + static clearProfile(): void { + this.cachedProfile = null; + } + + static getCachedProfile(): UserProfile | null { + return this.cachedProfile; + } + + static async openLogin(): Promise { + const uuid = uuidv4(); + const authUrl = `${ROSETTA_CLOUD_BASE_URL}/api/device-auth/start?uuid=${uuid}`; + + await shell.openExternal(authUrl); + + return uuid; + } + + static async storeApiKey(apiKey: string): Promise { + try { + await SecureStorageService.setCredential( + this.API_KEY_STORAGE_KEY, + apiKey, + ); + + // eslint-disable-next-line no-console + console.log('API key stored successfully'); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to store API key:', error); + throw error; + } + } + + static async getApiKey(): Promise { + try { + return await SecureStorageService.getCredential(this.API_KEY_STORAGE_KEY); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to retrieve API key:', error); + return null; + } + } + + static async clearApiKey(): Promise { + try { + await SecureStorageService.deleteCredential(this.API_KEY_STORAGE_KEY); + + this.clearProfile(); + + // eslint-disable-next-line no-console + console.log('API key cleared successfully'); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to clear API key:', error); + throw error; + } + } + + static async isAuthenticated(): Promise { + const apiKey = await this.getApiKey(); + return !!apiKey; + } + + static async validateApiKey( + apiKey: string, + ): Promise<{ valid: boolean; error?: string }> { + try { + const response = await fetch( + `${ROSETTA_CLOUD_BASE_URL}/api/electron/profile`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (response.ok) { + return { valid: true }; + } + + if (response.status === 401) { + return { valid: false, error: 'Invalid API key' }; + } + + if (response.status === 404) { + return { + valid: false, + error: 'API key not found or user does not exist', + }; + } + + return { valid: false, error: `Validation failed: ${response.status}` }; + } catch (error) { + // eslint-disable-next-line no-console + console.error('API key validation error:', error); + return { valid: false, error: 'Unable to connect to Rosetta Cloud' }; + } + } +} diff --git a/src/main/utils/constants.ts b/src/main/utils/constants.ts index d920a8f2..aa6550c2 100644 --- a/src/main/utils/constants.ts +++ b/src/main/utils/constants.ts @@ -23,4 +23,6 @@ export const SNOWFLAKE_TYPE_MAP: Record = { export const AppUpdateTrackURL = 'https://dbt-studio-tracker.adaptivescale.workers.dev/api/track'; -export const ROSETTA_CLOUD_BASE_URL = 'http://localhost:3000/'; +export const CLOUD_DASHBOARD_API_KEY = 'cloud-api-key'; + +export const ROSETTA_CLOUD_BASE_URL = 'https://dashboard.tolstudios.net'; diff --git a/src/main/utils/fileHelper.ts b/src/main/utils/fileHelper.ts index 3bd407f0..0d1d21fd 100644 --- a/src/main/utils/fileHelper.ts +++ b/src/main/utils/fileHelper.ts @@ -70,8 +70,6 @@ export const loadDefaultSettings = (): SettingsType => { pythonPath: '', pythonBinary: '', isSetup: 'false', - cloudWorkspaceUrl: '', - cloudWorkspaceLastSyncedAt: '', }; }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 45b4661c..dc5553e2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -54,6 +54,7 @@ const App: React.FC = () => { /> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/components/dbtModelButtons/ModelSplitButton.tsx b/src/renderer/components/dbtModelButtons/ModelSplitButton.tsx index 58576810..e99a0421 100644 --- a/src/renderer/components/dbtModelButtons/ModelSplitButton.tsx +++ b/src/renderer/components/dbtModelButtons/ModelSplitButton.tsx @@ -9,12 +9,14 @@ import { Icon } from '../icon'; import { extractModelNameFromPath } from '../../helpers/utils'; import { CompileModal } from '../modals/CompileModal'; import { MiniSqlEditorModal } from '../modals/MiniSqlEditorModal'; +import { PushToCloudModal } from '../modals'; import useDbt from '../../hooks/useDbt'; import { queryData, getConnectionById, } from '../../services/connectors.service'; import type { PreviewResult } from '../../../types/frontend'; +import type { DbtCommandType } from '../../../types/backend'; interface ModelSplitButtonProps { modelPath: string; @@ -23,6 +25,7 @@ interface ModelSplitButtonProps { fileContent?: string; isRunningDbt: boolean; isRunningRosettaDbt: boolean; + environment?: 'local' | 'cloud'; } export const ModelSplitButton: React.FC = ({ @@ -32,6 +35,7 @@ export const ModelSplitButton: React.FC = ({ fileContent, isRunningDbt, isRunningRosettaDbt, + environment = 'local', }) => { const [isCompiling, setIsCompiling] = useState(false); const [showCompileModal, setShowCompileModal] = useState(false); @@ -46,6 +50,10 @@ export const ModelSplitButton: React.FC = ({ ); const [previewError, setPreviewError] = useState(); + // Cloud execution state + const [runInCloudModal, setRunInCloudModal] = useState(); + const [cloudDbtArguments, setCloudDbtArguments] = useState(''); + const { compile: dbtCompileModel, run: dbtRunModel, @@ -53,7 +61,23 @@ export const ModelSplitButton: React.FC = ({ isRunning: isRunningDbtModel, list: dbtList, build: dbtBuildModel, - } = useDbt(); + } = useDbt(undefined, (command) => { + setRunInCloudModal(command); + }); + + // Helper function to handle cloud vs local execution + const executeCommand = async ( + command: DbtCommandType, + localHandler: () => Promise, + dbtArgs?: string, + ) => { + if (environment === 'cloud') { + setCloudDbtArguments(dbtArgs || ''); + setRunInCloudModal(command); + } else { + await localHandler(); + } + }; const handleCompileModel = async () => { if (!isDbtConfigured) { @@ -201,294 +225,483 @@ export const ModelSplitButton: React.FC = ({ }; const handleRunModel = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for single model execution - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Run the single model using dbt run --select - await dbtRunModel(project, modelName); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Model execution failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'run', + async () => { + try { + // Run the single model using dbt run --select + await dbtRunModel(project, modelName); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Model execution failed: ${errorMessage}`); + } + }, + `--select ${modelName}`, + ); }; const handleTestModel = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for single model testing - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Run tests on the single model using dbt test --select - await dbtTestModel(project, modelName); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Model tests failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'test', + async () => { + try { + // Run tests on the single model using dbt test --select + await dbtTestModel(project, modelName); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Model tests failed: ${errorMessage}`); + } + }, + `--select ${modelName}`, + ); }; const handleRunModelDownstream = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for downstream execution - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Run the model and all its downstream dependencies using dbt run --select model_name+ - // The + suffix tells dbt to include all downstream models - await dbtRunModel(project, `${modelName}+`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Downstream run failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'run', + async () => { + try { + // Run the model and all its downstream dependencies using dbt run --select model_name+ + // The + suffix tells dbt to include all downstream models + await dbtRunModel(project, `${modelName}+`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Downstream run failed: ${errorMessage}`); + } + }, + `--select ${modelName}+`, + ); }; const handleRunModelUpstream = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for upstream execution - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Run the model and all its upstream dependencies using dbt run --select +model_name - // The + prefix tells dbt to include all upstream models (parents) - await dbtRunModel(project, `+${modelName}`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Upstream run failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'run', + async () => { + try { + // Run the model and all its upstream dependencies using dbt run --select +model_name + // The + prefix tells dbt to include all upstream models (parents) + await dbtRunModel(project, `+${modelName}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Upstream run failed: ${errorMessage}`); + } + }, + `--select +${modelName}`, + ); }; const handleRunModelBothDirections = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for both directions execution - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Run the model and all its upstream and downstream dependencies using dbt run --select +model_name+ - // The + prefix and suffix tells dbt to include both upstream and downstream models - await dbtRunModel(project, `+${modelName}+`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Full dependency run failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'run', + async () => { + try { + // Run the model and all its upstream and downstream dependencies using dbt run --select +model_name+ + // The + prefix and suffix tells dbt to include both upstream and downstream models + await dbtRunModel(project, `+${modelName}+`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Full dependency run failed: ${errorMessage}`); + } + }, + `--select +${modelName}+`, + ); }; const handleTestModelDownstream = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for downstream testing - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Run tests on the model and all its downstream dependencies using dbt test --select model_name+ - // The + suffix tells dbt to include all downstream models - await dbtTestModel(project, `${modelName}+`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Downstream tests failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'test', + async () => { + try { + // Run tests on the model and all its downstream dependencies using dbt test --select model_name+ + // The + suffix tells dbt to include all downstream models + await dbtTestModel(project, `${modelName}+`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Downstream tests failed: ${errorMessage}`); + } + }, + `--select ${modelName}+`, + ); }; const handleTestModelUpstream = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for upstream testing - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Run tests on the model and all its upstream dependencies using dbt test --select +model_name - // The + prefix tells dbt to include all upstream models (parents) - await dbtTestModel(project, `+${modelName}`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Upstream tests failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'test', + async () => { + try { + // Run tests on the model and all its upstream dependencies using dbt test --select +model_name + // The + prefix tells dbt to include all upstream models (parents) + await dbtTestModel(project, `+${modelName}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Upstream tests failed: ${errorMessage}`); + } + }, + `--select +${modelName}`, + ); }; const handleTestModelBothDirections = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for both directions testing - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Run tests on the model and all its upstream and downstream dependencies using dbt test --select +model_name+ - // The + prefix and suffix tells dbt to include both upstream and downstream models - await dbtTestModel(project, `+${modelName}+`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Full dependency tests failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'test', + async () => { + try { + // Run tests on the model and all its upstream and downstream dependencies using dbt test --select +model_name+ + // The + prefix and suffix tells dbt to include both upstream and downstream models + await dbtTestModel(project, `+${modelName}+`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Full dependency tests failed: ${errorMessage}`); + } + }, + `--select +${modelName}+`, + ); }; const handleBuildModel = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for single model building - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Build the single model using dbt build --select - // This will run the model + tests + seeds + snapshots - await dbtBuildModel(project, modelName); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Model build failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'build', + async () => { + try { + // Build the single model using dbt build --select + // This will run the model + tests + seeds + snapshots + await dbtBuildModel(project, modelName); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Model build failed: ${errorMessage}`); + } + }, + `--select ${modelName}`, + ); }; const handleBuildModelDownstream = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for downstream building - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Build the model and all its downstream dependencies using dbt build --select model_name+ - // The + suffix tells dbt to include all downstream models - await dbtBuildModel(project, `${modelName}+`); - toast.success( - `Model '${modelName}' and downstream models built successfully`, - ); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Downstream build failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'build', + async () => { + try { + // Build the model and all its downstream dependencies using dbt build --select model_name+ + // The + suffix tells dbt to include all downstream models + await dbtBuildModel(project, `${modelName}+`); + toast.success( + `Model '${modelName}' and downstream models built successfully`, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Downstream build failed: ${errorMessage}`); + } + }, + `--select ${modelName}+`, + ); }; const handleBuildModelUpstream = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for upstream building - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Build the model and all its upstream dependencies using dbt build --select +model_name - // The + prefix tells dbt to include all upstream models (parents) - await dbtBuildModel(project, `+${modelName}`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Upstream build failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'build', + async () => { + try { + // Build the model and all its upstream dependencies using dbt build --select +model_name + // The + prefix tells dbt to include all upstream models (parents) + await dbtBuildModel(project, `+${modelName}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Upstream build failed: ${errorMessage}`); + } + }, + `--select +${modelName}`, + ); }; const handleBuildModelBothDirections = async () => { - if (!isDbtConfigured) { + if (!isDbtConfigured && environment === 'local') { toast.info('Please configure dbt path in settings'); return; } - try { - // Extract model name from path for both directions building - const modelName = extractModelNameFromPath(modelPath); - if (!modelName) { - toast.error('Could not extract model name from path'); - return; - } - - // Build the model and all its upstream and downstream dependencies using dbt build --select +model_name+ - // The + prefix and suffix tells dbt to include both upstream and downstream models - await dbtBuildModel(project, `+${modelName}+`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Full dependency build failed: ${errorMessage}`); + // Extract model name for both local and cloud execution + const modelName = extractModelNameFromPath(modelPath); + if (!modelName) { + toast.error('Could not extract model name from path'); + return; } + + await executeCommand( + 'build', + async () => { + try { + // Build the model and all its upstream and downstream dependencies using dbt build --select +model_name+ + // The + prefix and suffix tells dbt to include both upstream and downstream models + await dbtBuildModel(project, `+${modelName}+`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Full dependency build failed: ${errorMessage}`); + } + }, + `--select +${modelName}+`, + ); }; + // Define all menu items with environment restrictions + const allMenuItems = [ + // Production DBT Commands (Available in both environments) + { + name: 'Run', + onClick: handleRunModel, + leftIcon: , + subTitle: 'Run the dbt model', + localOnly: false, + }, + { + name: 'Run model+ (Downstream)', + onClick: handleRunModelDownstream, + leftIcon: , + subTitle: 'Run the model and all its downstream dependencies', + localOnly: false, + }, + { + name: 'Run +model (Upstream)', + onClick: handleRunModelUpstream, + leftIcon: , + subTitle: 'Run the model and all its upstream dependencies', + localOnly: false, + }, + { + name: 'Run +model+ (Up/downstream)', + onClick: handleRunModelBothDirections, + leftIcon: , + subTitle: + 'Run the model and all its upstream and downstream dependencies', + localOnly: false, + }, + { + name: 'Build Model', + onClick: handleBuildModel, + leftIcon: , + subTitle: 'Build model with tests and validation', + localOnly: false, + }, + { + name: 'Build model+ (Downstream)', + onClick: handleBuildModelDownstream, + leftIcon: , + subTitle: 'Build the model and all its downstream dependencies', + localOnly: false, + }, + { + name: 'Build +model (Upstream)', + onClick: handleBuildModelUpstream, + leftIcon: , + subTitle: 'Build the model and all its upstream dependencies', + localOnly: false, + }, + { + name: 'Build +model+ (Up/downstream)', + onClick: handleBuildModelBothDirections, + leftIcon: , + subTitle: + 'Build the model and all its upstream and downstream dependencies', + localOnly: false, + }, + { + name: 'Test', + onClick: handleTestModel, + leftIcon: , + subTitle: 'Run the dbt test', + localOnly: false, + }, + { + name: 'Test model+ (Downstream)', + onClick: handleTestModelDownstream, + leftIcon: , + subTitle: 'Test the model and all its downstream dependencies', + localOnly: false, + }, + { + name: 'Test +model (Upstream)', + onClick: handleTestModelUpstream, + leftIcon: , + subTitle: 'Test the model and all its upstream dependencies', + localOnly: false, + }, + { + name: 'Test +model+ (Up/downstream)', + onClick: handleTestModelBothDirections, + leftIcon: , + subTitle: + 'Test the model and all its upstream and downstream dependencies', + localOnly: false, + }, + // Local Development Commands (Local Only) + { + name: 'Compile', + onClick: handleCompileModel, + leftIcon: , + subTitle: 'Compile the dbt model', + localOnly: true, // Compile is for local development/debugging + }, + { + name: 'Preview', + onClick: handlePreviewModel, + leftIcon: , + subTitle: 'Preview the dbt model data', + localOnly: true, // Preview is for local development/debugging + }, + ]; + + // Filter menu items based on environment + // In cloud mode: hide local development tools (compile, preview) + // In local mode: show all items + const filteredMenuItems = allMenuItems.filter((item) => { + if (environment === 'cloud') { + return !item.localOnly; + } + return true; // Show all items in local environment + }); + return ( <> = ({ isPreviewing } leftIcon={} - menuItems={[ - { - name: 'Run', - onClick: handleRunModel, - leftIcon: , - subTitle: 'Run the dbt model', - }, - { - name: 'Run model+ (Downstream)', - onClick: handleRunModelDownstream, - leftIcon: , - subTitle: 'Run the model and all its downstream dependencies', - }, - { - name: 'Run +model (Upstream)', - onClick: handleRunModelUpstream, - leftIcon: , - subTitle: 'Run the model and all its upstream dependencies', - }, - { - name: 'Run +model+ (Up/downstream)', - onClick: handleRunModelBothDirections, - leftIcon: , - subTitle: - 'Run the model and all its upstream and downstream dependencies', - }, - { - name: 'Build Model', - onClick: handleBuildModel, - leftIcon: , - subTitle: 'Build model with tests and validation', - }, - { - name: 'Build model+ (Downstream)', - onClick: handleBuildModelDownstream, - leftIcon: , - subTitle: 'Build the model and all its downstream dependencies', - }, - { - name: 'Build +model (Upstream)', - onClick: handleBuildModelUpstream, - leftIcon: , - subTitle: 'Build the model and all its upstream dependencies', - }, - { - name: 'Build +model+ (Up/downstream)', - onClick: handleBuildModelBothDirections, - leftIcon: , - subTitle: - 'Build the model and all its upstream and downstream dependencies', - }, - { - name: 'Test', - onClick: handleTestModel, - leftIcon: , - subTitle: 'Run the dbt test', - }, - { - name: 'Test model+ (Downstream)', - onClick: handleTestModelDownstream, - leftIcon: , - subTitle: 'Test the model and all its downstream dependencies', - }, - { - name: 'Test +model (Upstream)', - onClick: handleTestModelUpstream, - leftIcon: , - subTitle: 'Test the model and all its upstream dependencies', - }, - { - name: 'Test +model+ (Up/downstream)', - onClick: handleTestModelBothDirections, - leftIcon: , - subTitle: - 'Test the model and all its upstream and downstream dependencies', - }, - { - name: 'Compile', - onClick: handleCompileModel, - leftIcon: , - subTitle: 'Compile the dbt model', - }, - { - name: 'Preview', - onClick: handlePreviewModel, - leftIcon: , - subTitle: 'Preview the dbt model data', - }, - ]} + menuItems={filteredMenuItems.map((item) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { localOnly, ...menuItem } = item; + return menuItem; + })} /> = ({ loading={isPreviewing} error={previewError} /> + + {runInCloudModal && ( + { + setRunInCloudModal(undefined); + setCloudDbtArguments(''); + }} + project={project} + command={runInCloudModal} + initialDbtArguments={cloudDbtArguments} + /> + )} ); }; diff --git a/src/renderer/components/dbtModelButtons/ProjectDbtSplitButton.tsx b/src/renderer/components/dbtModelButtons/ProjectDbtSplitButton.tsx index 6501a95c..14688868 100644 --- a/src/renderer/components/dbtModelButtons/ProjectDbtSplitButton.tsx +++ b/src/renderer/components/dbtModelButtons/ProjectDbtSplitButton.tsx @@ -4,9 +4,19 @@ import { toast } from 'react-toastify'; import { SplitButton } from '../splitButton'; import { icons } from '../../../../assets'; import { Icon } from '../icon'; -import { Command, CommandType, Project } from '../../../types/backend'; +import { + Command, + CommandType, + DbtCommandType, + Project, +} from '../../../types/backend'; import { useDbt, useProcess } from '../../hooks'; -import { StagingModal, IncrementalModal, RawLayerModal } from '../modals'; +import { + StagingModal, + IncrementalModal, + RawLayerModal, + PushToCloudModal, +} from '../modals'; import { pathJoin } from '../../services/settings.services'; interface ProjectDbtSplitButtonProps { @@ -17,11 +27,10 @@ interface ProjectDbtSplitButtonProps { isRunningDbt: boolean; isRunningRosettaDbt: boolean; connection?: any; + environment?: 'local' | 'cloud'; // Function handlers that are used elsewhere in ProjectDetails rosettaDbt: (project: Project, command: Command) => Promise; handleBusinessLayerClick: (path: string) => void; - // eslint-disable-next-line react/no-unused-prop-types - onRunOnCloudClick: () => void; } export const ProjectDbtSplitButton: React.FC = ({ @@ -32,10 +41,13 @@ export const ProjectDbtSplitButton: React.FC = ({ isRunningDbt, isRunningRosettaDbt, connection, + environment = 'local', rosettaDbt, handleBusinessLayerClick, }) => { // Functions that are only used in this component - moved inside + const [runInCloudModal, setRunInCloudModal] = + React.useState(); const { run: dbtRun, @@ -47,7 +59,9 @@ export const ProjectDbtSplitButton: React.FC = ({ docsGenerate: dbtDocsGenerate, deps: dbtDeps, seed: dbtSeed, - } = useDbt(); + } = useDbt(undefined, (command) => { + setRunInCloudModal(command); + }); const { start, stop, isRunning } = useProcess(); const [stagingPath, setStagingPath] = React.useState(''); const [businessPath, setBusinessPath] = React.useState(''); @@ -87,6 +101,262 @@ export const ProjectDbtSplitButton: React.FC = ({ loadDefaults(); }, [project.path]); + // Define all menu items with environment restrictions + const allMenuItems = [ + // Rosetta Layer Generation Commands (Local Only) + { + name: 'Raw Layer', + onClick: () => { + if (!rosettaPath) { + toast.info('Please configure RosettaDB path in settings'); + return; + } + setOpenRawLayerModal(true); + }, + leftIcon: ( + Rosetta + ), + subTitle: 'Generate dbt Raw Layer', + localOnly: true, + }, + { + name: 'Staging Layer', + onClick: () => { + if (!rosettaPath) { + toast.info('Please configure RosettaDB path in settings'); + return; + } + setStagingModal(true); + }, + leftIcon: ( + Rosetta + ), + subTitle: 'Generate dbt Staging Layer (runs extract first)', + localOnly: true, + }, + { + name: 'Incremental/Enhanced Layer', + onClick: () => { + if (!rosettaPath) { + toast.info('Please configure RosettaDB path in settings'); + return; + } + setIncrementalModal(true); + }, + leftIcon: ( + Rosetta + ), + subTitle: 'Generate dbt Incremental Layer', + localOnly: true, + }, + { + name: 'Business Layer', + onClick: () => { + if (!rosettaPath) { + toast.info('Please configure RosettaDB path in settings'); + return; + } + handleBusinessLayerClick(businessPath); + }, + leftIcon: ( + Rosetta + ), + subTitle: 'Generate dbt Business Layer', + localOnly: true, + }, + // Production DBT Commands (Available in both environments) + { + name: 'Run', + onClick: () => { + if (!isDbtConfigured) { + toast.info('Please configure dbt path in settings'); + return; + } + dbtRun(project); + }, + leftIcon: , + subTitle: 'Run the dbt project', + localOnly: false, + }, + { + name: 'Test', + onClick: () => { + if (!isDbtConfigured) { + toast.info('Please configure dbt path in settings'); + return; + } + dbtTest(project); + }, + leftIcon: , + subTitle: 'Run the dbt test', + localOnly: false, + }, + { + name: 'Build', + onClick: () => { + if (!isDbtConfigured) { + toast.info('Please configure dbt path in settings'); + return; + } + dbtBuild(project); + }, + leftIcon: , + subTitle: 'Build the dbt project', + localOnly: false, + }, + { + name: 'Compile', + onClick: () => { + if (!isDbtConfigured) { + toast.info('Please configure dbt path in settings'); + return; + } + dbtCompileProject(project); + }, + leftIcon: , + subTitle: 'Compile the dbt project', + localOnly: false, + }, + { + name: 'Debug', + onClick: () => { + if (!isDbtConfigured) { + toast.info('Please configure dbt path in settings'); + return; + } + dbtDebug(project); + }, + leftIcon: , + subTitle: 'Debug dbt connections and project', + localOnly: true, // Debug is for local development + }, + { + name: 'Generate Docs', + onClick: () => { + if (!isDbtConfigured) { + toast.info('Please configure dbt path in settings'); + return; + } + dbtDocsGenerate(project); + }, + leftIcon: , + subTitle: 'Generate documentation for the project', + localOnly: true, // Docs generation is typically local + }, + { + name: ( +
+ Serve Docs + {isRunning ? : } +
+ ), + onClick: () => { + if (isRunning) { + stop(); + return; + } + start( + `cd "${project.path}" && "${dbtPath}" docs serve`, + connection?.connection?.name ?? '', + ); + }, + leftIcon: , + subTitle: 'Serve the documentation website', + localOnly: true, // Serve docs is local development only + }, + { + name: 'Clean', + onClick: () => { + if (!isDbtConfigured) { + toast.info('Please configure dbt path in settings'); + return; + } + dbtClean(project); + }, + leftIcon: , + subTitle: 'Clean the dbt project', + localOnly: false, + }, + { + name: 'Deps', + onClick: () => { + if (!isDbtConfigured) { + toast.info('Please configure dbt path in settings'); + return; + } + dbtDeps(project); + }, + leftIcon: , + subTitle: 'Install dbt dependencies', + localOnly: false, + }, + { + name: 'Seed', + onClick: () => { + if (!isDbtConfigured) { + toast.info('Please configure dbt path in settings'); + return; + } + dbtSeed(project); + }, + leftIcon: , + subTitle: 'Seed the dbt project', + localOnly: false, + }, + ]; + + // Filter menu items based on environment + // In cloud mode: hide Rosetta layer generation and local development tools + // In local mode: show all items + const filteredMenuItems = allMenuItems.filter((item) => { + if (environment === 'cloud') { + return !item.localOnly; + } + return true; // Show all items in local environment + }); + return ( <> = ({ disabled={isRunningDbt || isRunningRosettaDbt} isLoading={isRunningDbt || isRunningRosettaDbt} leftIcon={} - menuItems={[ - { - name: 'Raw Layer', - onClick: () => { - if (!rosettaPath) { - toast.info('Please configure RosettaDB path in settings'); - return; - } - setOpenRawLayerModal(true); - }, - leftIcon: ( - Rosetta - ), - subTitle: 'Generate dbt Raw Layer', - }, - { - name: 'Staging Layer', - onClick: () => { - if (!rosettaPath) { - toast.info('Please configure RosettaDB path in settings'); - return; - } - setStagingModal(true); - }, - leftIcon: ( - Rosetta - ), - subTitle: 'Generate dbt Staging Layer (runs extract first)', - }, - { - name: 'Incremental/Enhanced Layer', - onClick: () => { - if (!rosettaPath) { - toast.info('Please configure RosettaDB path in settings'); - return; - } - setIncrementalModal(true); - }, - leftIcon: ( - Rosetta - ), - subTitle: 'Generate dbt Incremental Layer', - }, - { - name: 'Business Layer', - onClick: () => { - if (!rosettaPath) { - toast.info('Please configure RosettaDB path in settings'); - return; - } - handleBusinessLayerClick(businessPath); - }, - leftIcon: ( - Rosetta - ), - subTitle: 'Generate dbt Business Layer', - }, - { - name: 'Run', - onClick: () => { - if (!isDbtConfigured) { - toast.info('Please configure dbt path in settings'); - return; - } - dbtRun(project); - }, - leftIcon: , - subTitle: 'Run the dbt project', - }, - // { - // name: 'Run on cloud', - // onClick: () => { - // onRunOnCloudClick(); - // }, - // leftIcon: , - // subTitle: 'Run on cloud', - // }, - { - name: 'Test', - onClick: () => { - if (!isDbtConfigured) { - toast.info('Please configure dbt path in settings'); - return; - } - dbtTest(project); - }, - leftIcon: , - subTitle: 'Run the dbt test', - }, - { - name: 'Build', - onClick: () => { - if (!isDbtConfigured) { - toast.info('Please configure dbt path in settings'); - return; - } - dbtBuild(project); - }, - leftIcon: , - subTitle: 'Build the dbt project', - }, - { - name: 'Compile', - onClick: () => { - if (!isDbtConfigured) { - toast.info('Please configure dbt path in settings'); - return; - } - dbtCompileProject(project); - }, - leftIcon: , - subTitle: 'Compile the dbt project', - }, - { - name: 'Debug', - onClick: () => { - if (!isDbtConfigured) { - toast.info('Please configure dbt path in settings'); - return; - } - dbtDebug(project); - }, - leftIcon: , - subTitle: 'Debug dbt connections and project', - }, - { - name: 'Generate Docs', - onClick: () => { - if (!isDbtConfigured) { - toast.info('Please configure dbt path in settings'); - return; - } - dbtDocsGenerate(project); - }, - leftIcon: , - subTitle: 'Generate documentation for the project', - }, - { - name: ( -
- Serve Docs - {isRunning ? : } -
- ), - onClick: () => { - if (isRunning) { - stop(); - return; - } - start( - `cd "${project.path}" && "${dbtPath}" docs serve`, - connection?.connection?.name ?? '', - ); - }, - leftIcon: , - subTitle: 'Serve the documentation website', - }, - { - name: 'Clean', - onClick: () => { - if (!isDbtConfigured) { - toast.info('Please configure dbt path in settings'); - return; - } - dbtClean(project); - }, - leftIcon: , - subTitle: 'Clean the dbt project', - }, - { - name: 'Deps', - onClick: () => { - if (!isDbtConfigured) { - toast.info('Please configure dbt path in settings'); - return; - } - dbtDeps(project); - }, - leftIcon: , - subTitle: 'Clean the dbt project', - }, - { - name: 'Seed', - onClick: () => { - if (!isDbtConfigured) { - toast.info('Please configure dbt path in settings'); - return; - } - dbtSeed(project); - }, - leftIcon: , - subTitle: 'Seed the dbt project', - }, - ]} + menuItems={filteredMenuItems.map((item) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { localOnly, ...menuItem } = item; + return menuItem; + })} /> {openRawLayerModal && project?.path && ( = ({ }} /> )} + {runInCloudModal && ( + setRunInCloudModal(undefined)} + project={project} + command={runInCloudModal} + /> + )} ); }; diff --git a/src/renderer/components/menu/index.tsx b/src/renderer/components/menu/index.tsx index 91e440bb..49c5f34d 100644 --- a/src/renderer/components/menu/index.tsx +++ b/src/renderer/components/menu/index.tsx @@ -1,22 +1,49 @@ import React from 'react'; -import { AppBar, IconButton, Tooltip, useTheme } from '@mui/material'; +import { + AppBar, + IconButton, + Tooltip, + useTheme, + CircularProgress, + Button, +} from '@mui/material'; import { Settings, ArrowDownward, FormatListNumbered, + Cloud, + Computer, + OpenInNew, } from '@mui/icons-material'; +import { toast } from 'react-toastify'; import { useNavigate, useLocation } from 'react-router-dom'; import { BranchDropdownToggle, + EnvironmentSwitch, + EnvironmentSwitchContainer, IconsContainer, Logo, StyledToolbar, + SwitchIcon, + AuthButtonContent, + AuthIcon, + AuthLabel, } from './styles'; -import { icons, logo } from '../../../../assets'; +import { icons, logo, rosettaIcon } from '../../../../assets'; +import { utils } from '../../helpers'; +import { ROSETTA_CLOUD_BASE_URL } from '../../../main/utils/constants'; import { useGetProjects, useGetSelectedProject, useSelectProject, + useProfile, + useProfileSubscription, + useApiKey, + useAuthLogin, + useAuthLogout, + useAuthSubscription, + useUpdateSettings, + useGetSettings, } from '../../controllers'; import { SimpleDropdownMenu } from '../simpleDropdown'; import { Icon } from '../icon'; @@ -28,15 +55,47 @@ export const Menu: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); const { mutateAsync: selectProject } = useSelectProject(); + const { data: settings } = useGetSettings(); + const { mutate: updateSettings } = useUpdateSettings(); const theme = useTheme(); const { isSidebarOpen, setIsSidebarOpen, isChatOpen, setIsChatOpen } = useAppContext(); + // Auth hooks - Updated to use API key + const { data: apiKey, isLoading: apiKeyLoading } = useApiKey(); + const { mutate: login, isLoading: loginLoading } = useAuthLogin({ + onSuccess: () => { + toast.success( + 'Login initiated! Please complete authentication in your browser.', + ); + }, + onError: (error) => { + toast.error(`Login failed: ${error.message || 'Unknown error'}`); + }, + }); + const { isLoading: logoutLoading } = useAuthLogout(); + + // Subscribe to auth success events + useAuthSubscription(); + + // Subscribe to profile events + useProfileSubscription(); + + // Get profile data + const { data: profile } = useProfile(); + + const isAuthLoading = apiKeyLoading || loginLoading || logoutLoading; + + const handleAuthButtonClick = () => { + login(); + }; + const { data: project } = useGetSelectedProject(); const { data: projects = [] } = useGetProjects(); const isProjectSelected = Boolean(project?.id); const isSettingsActive = location.pathname.includes('/settings'); + const isOnProjectDetails = location.pathname === '/app'; const handleLogoClick = () => { @@ -98,7 +157,139 @@ export const Menu: React.FC = () => { /> )} - + + {/* Authentication - Only show when not logged in */} + {!apiKey && ( + + + + )} + + {/* Link to Rosetta Cloud Dashboard - Only show when logged in */} + {apiKey && ( + + + + )} + + {/* Environment Switch */} + {profile && ( + <> + + + { + const newEnv = event.target.checked ? 'cloud' : 'local'; + updateSettings({ + ...settings!, + env: newEnv, + }); + toast.info( + `Switched to ${newEnv === 'cloud' ? 'Cloud' : 'Local'} environment`, + ); + }} + inputProps={{ 'aria-label': 'Environment switcher' }} + /> + + {settings?.env === 'cloud' ? ( + + ) : ( + + )} + + + + + {settings?.env === 'cloud' ? 'Cloud' : 'Local'} + + + )} + {isProjectSelected && isOnProjectDetails && ( { )} - ({ background: theme.palette.background.paper, @@ -25,3 +25,84 @@ export const BranchDropdownToggle = styled('div')(() => ({ alignItems: 'center', gap: 10, })); + +export const EnvironmentSwitchContainer = styled(Box)(() => ({ + position: 'relative', + display: 'inline-flex', + alignItems: 'center', +})); + +export const EnvironmentSwitch = styled(Switch)(({ theme }) => ({ + width: 42, + height: 24, + padding: 0, + '& .MuiSwitch-switchBase': { + padding: 0, + margin: 2, + transitionDuration: '300ms', + '&.Mui-checked': { + transform: 'translateX(18px)', + '& + .MuiSwitch-track': { + backgroundColor: theme.palette.action.selected, + opacity: 1, + border: 0, + }, + }, + }, + '& .MuiSwitch-thumb': { + boxSizing: 'border-box', + width: 20, + height: 20, + backgroundColor: theme.palette.primary.main, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + '& .MuiSwitch-track': { + borderRadius: 24 / 2, + backgroundColor: theme.palette.action.selected, + opacity: 1, + transition: theme.transitions.create(['background-color'], { + duration: 500, + }), + }, +})); + +export const SwitchIcon = styled(Box)(({ theme }) => ({ + position: 'absolute', + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none', + zIndex: 1, + transition: theme.transitions.create(['left'], { + duration: 300, + }), + '&.checked': { + left: 'calc(50% + 9px)', + }, + '&.unchecked': { + left: 'calc(50% - 9px)', + }, +})); + +export const AuthButtonContent = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: 8, + color: theme.palette.text.primary, +})); + +export const AuthIcon = styled('img')(() => ({ + width: 18, + height: 18, +})); + +export const AuthLabel = styled('span')(({ theme }) => ({ + fontWeight: 300, + fontSize: '0.75rem', + color: theme.palette.text.primary, +})); diff --git a/src/renderer/components/modals/pushToCloudModal/index.tsx b/src/renderer/components/modals/pushToCloudModal/index.tsx index a89a0eff..013f41e0 100644 --- a/src/renderer/components/modals/pushToCloudModal/index.tsx +++ b/src/renderer/components/modals/pushToCloudModal/index.tsx @@ -8,117 +8,195 @@ import { CircularProgress, IconButton, InputAdornment, + Chip, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, + Paper, + Stack, + useTheme, + alpha, + Skeleton, } from '@mui/material'; import { Visibility, VisibilityOff, - CloudUploadOutlined, Close, + Delete, + ExpandMore, + CloudUpload, + Lock, + Key, + AddOutlined, } from '@mui/icons-material'; import { toast } from 'react-toastify'; import { Modal } from '../modal'; -import { usePushProjectToCloud } from '../../../controllers'; -import { Project } from '../../../../types/backend'; -import useSecureStorage from '../../../hooks/useSecureStorage'; +import { + useGetLocalChanges, + useGetRepoInfo, + useGetSecrets, + usePushProjectToCloud, +} from '../../../controllers'; +import { DbtCommandType, Project } from '../../../../types/backend'; + +interface EnvironmentVariable { + key: string; + value: string; + id: string; + isEdited?: boolean; + originalValue?: string; // Store original encrypted value +} interface PushToCloudModalProps { isOpen: boolean; onClose: () => void; - project: Project | null; + project: Project; + command: DbtCommandType; + initialDbtArguments?: string; } +const RESERVED_KEYS = ['ROSETTA_GIT_USER', 'ROSETTA_GIT_PASSWORD']; + export const PushToCloudModal: React.FC = ({ isOpen, onClose, project, + command, + initialDbtArguments = '', }) => { - const { getCloudApiKey } = useSecureStorage(); - const { - mutateAsync: pushProject, - isLoading: isPushing, - reset: resetMutation, - } = usePushProjectToCloud(); - - const [title, setTitle] = React.useState(''); + const theme = useTheme(); + const { data: localChanges, isLoading: isLoadingChanges } = + useGetLocalChanges(project.path); + const { data: repoInfo, isLoading: isLoadingRepo } = useGetRepoInfo( + project.path, + ); + const { mutateAsync: pushProject, isLoading: isPushing } = + usePushProjectToCloud(); + const { data: secrets = [] } = useGetSecrets(project.id); + + const [title, setTitle] = React.useState(project.name); const [gitUrl, setGitUrl] = React.useState(''); const [gitBranch, setGitBranch] = React.useState('main'); - const [apiKey, setApiKey] = React.useState(null); - const [isLoadingKey, setIsLoadingKey] = React.useState(false); const [urlError, setUrlError] = React.useState(''); const [titleError, setTitleError] = React.useState(''); const [formError, setFormError] = React.useState(''); + const [githubUsername, setGithubUsername] = React.useState(''); const [githubPassword, setGithubPassword] = React.useState(''); + const [originalGithubUsername, setOriginalGithubUsername] = + React.useState(''); + const [originalGithubPassword, setOriginalGithubPassword] = + React.useState(''); const [showGithubPassword, setShowGithubPassword] = React.useState(false); + const [isGithubUsernameEdited, setIsGithubUsernameEdited] = + React.useState(false); + const [isGithubPasswordEdited, setIsGithubPasswordEdited] = + React.useState(false); - const handleGitUrlChange = React.useCallback( - ({ target: { value } }: React.ChangeEvent) => { - setGitUrl(value); - if (urlError) { - setUrlError(''); - } - }, - [urlError], + const [environmentVariables, setEnvironmentVariables] = React.useState< + EnvironmentVariable[] + >([]); + const [newEnvKey, setNewEnvKey] = React.useState(''); + const [newEnvValue, setNewEnvValue] = React.useState(''); + const [dbtArguments, setDbtArguments] = React.useState(initialDbtArguments); + + // Update dbt arguments when the prop changes + React.useEffect(() => { + setDbtArguments(initialDbtArguments); + }, [initialDbtArguments]); + + const isRunMode = React.useMemo( + () => !!project?.externalId, + [project?.externalId], ); - const resetForm = React.useCallback(() => { - setTitle(project?.name ?? ''); - setGitUrl(''); - setGitBranch('main'); - setUrlError(''); - setTitleError(''); - setFormError(''); - setApiKey(null); - setGithubUsername(''); - setGithubPassword(''); - setShowGithubPassword(false); - }, [project?.name]); + const hasLocalChanges = React.useMemo(() => { + return ( + !!localChanges?.hasUntracked || + !!localChanges?.hasUncommitted || + !!localChanges?.hasUnpushed + ); + }, [localChanges]); + + const isLoading = isLoadingRepo || isLoadingChanges; React.useEffect(() => { - let isCancelled = false; - - const loadApiKey = async () => { - setIsLoadingKey(true); - try { - const key = await getCloudApiKey(); - if (!isCancelled) { - setApiKey(key); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to load cloud API key:', error); - toast.error('Unable to load the cloud API key.'); - if (!isCancelled) { - setApiKey(null); - } - } finally { - if (!isCancelled) { - setIsLoadingKey(false); - } + if (repoInfo) { + if (repoInfo.remoteUrl) { + setGitUrl(repoInfo.remoteUrl); + setUrlError(''); } - }; + if (repoInfo.currentBranch) { + setGitBranch(repoInfo.currentBranch); + } + } + }, [repoInfo]); - if (isOpen) { - resetForm(); - loadApiKey().catch((error) => { - // eslint-disable-next-line no-console - console.error('Unexpected error loading cloud API key:', error); - }); - } else { - resetMutation(); - setApiKey(null); - setFormError(''); - setUrlError(''); - setTitleError(''); + React.useEffect(() => { + if (secrets && secrets.length > 0) { + const loadedSecrets = secrets + .filter( + (secret) => + secret.name !== 'ROSETTA_GIT_USER' && + secret.name !== 'ROSETTA_GIT_PASSWORD', + ) + .map((secret) => ({ + id: secret.id, + key: secret.name, + value: secret.value, + originalValue: secret.value, // Store original encrypted value + isEdited: false, + })); + setEnvironmentVariables(loadedSecrets); + + // Load git credentials + const gitUser = secrets.find((s) => s.name === 'ROSETTA_GIT_USER'); + const gitPassword = secrets.find( + (s) => s.name === 'ROSETTA_GIT_PASSWORD', + ); + + if (gitUser) { + setGithubUsername(gitUser.value); + setOriginalGithubUsername(gitUser.value); + } + if (gitPassword) { + setGithubPassword(gitPassword.value); + setOriginalGithubPassword(gitPassword.value); + } } + }, [secrets]); - return () => { - isCancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, resetForm, resetMutation]); + const blockingError = React.useMemo(() => { + if (isLoading) return null; + + if (!repoInfo) { + return { + title: 'Unable to Load Repository Information', + message: + 'Could not retrieve Git repository information for this project. Please ensure the project is properly initialized with Git.', + }; + } + + if (!repoInfo.remoteUrl) { + return { + title: 'No Remote Repository Configured', + message: + 'This project does not have a remote origin URL configured. Please add a remote repository using Git before deploying to the cloud.', + }; + } + + if (!repoInfo.branchExistsOnRemote) { + return { + title: 'Current Branch Not Found on Remote', + message: `The current branch "${repoInfo.currentBranch}" does not exist on the remote repository. Please push your branch to the remote before deploying to the cloud.`, + }; + } - const validateForm = () => { + return null; + }, [repoInfo, isLoading]); + + const validateForm = React.useCallback(() => { let isValid = true; const trimmedTitle = title.trim(); const trimmedUrl = gitUrl.trim(); @@ -149,7 +227,38 @@ export const PushToCloudModal: React.FC = ({ } return isValid; - }; + }, [title, gitUrl, gitBranch]); + + const canSubmit = React.useMemo(() => { + if (!project?.id || isPushing || isLoading || !!blockingError) { + return false; + } + + const hasTitle = !!title.trim(); + const hasUrl = !!gitUrl.trim(); + const noErrors = !urlError && !titleError; + + return hasTitle && hasUrl && noErrors; + }, [ + project?.id, + isPushing, + isLoading, + blockingError, + title, + gitUrl, + urlError, + titleError, + ]); + + const handleGitUrlChange = React.useCallback( + (event: React.ChangeEvent) => { + setGitUrl(event.target.value); + if (urlError) { + setUrlError(''); + } + }, + [urlError], + ); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -159,52 +268,734 @@ export const PushToCloudModal: React.FC = ({ return; } - if (!apiKey) { - setFormError( - 'Cloud API key is required. Configure it in Settings > General > Cloud Workspace.', - ); - return; - } - if (!project?.id) { setFormError('Select a project to deploy.'); return; } try { + // Only include edited environment variables + const reducedSecrets = environmentVariables + .filter((env) => env.isEdited) + .reduce( + (acc, env) => { + acc[env.key] = env.value; + return acc; + }, + {} as Record, + ); + + // Only add git credentials if they were edited + if (isGithubUsernameEdited) { + reducedSecrets.ROSETTA_GIT_USER = githubUsername.trim(); + } + if (isGithubPasswordEdited) { + reducedSecrets.ROSETTA_GIT_PASSWORD = githubPassword; + } + + const fullCommand = dbtArguments.trim() + ? `${command} ${dbtArguments.trim()}` + : command; + await pushProject({ + id: project.id, title: title.trim(), gitUrl: gitUrl.trim(), gitBranch: gitBranch.trim() || 'main', - apiKey, - githubUsername: githubUsername.trim() || undefined, - githubPassword: githubPassword || undefined, + githubUsername: isRunMode ? undefined : githubUsername.trim(), + githubPassword: isRunMode ? undefined : githubPassword, + CUSTOM_DBT_COMMANDS: `dbt ${fullCommand}`, + secrets: reducedSecrets, }); - toast.success('Project deployed to cloud.'); + + await toast.success('Project deployed to cloud.'); onClose(); } catch (error) { const message = error instanceof Error ? error.message - : 'Failed to deploy project to Rosetta Cloud.'; + : `Failed to run project to Rosetta Cloud.`; setFormError(message); - // eslint-disable-next-line no-console - console.error('Failed to deploy project to cloud:', error); toast.error( - 'Unable to deploy project. Please review the form and try again.', + `Unable to run project. Please review the form and try again.`, + ); + } + }; + + const addEnvironmentVariable = React.useCallback(() => { + const trimmedKey = newEnvKey.trim().toUpperCase(); + const trimmedValue = newEnvValue.trim(); + + if (!trimmedKey || !trimmedValue) { + toast.error('Both key and value are required for environment variables'); + return; + } + + if (RESERVED_KEYS.includes(trimmedKey)) { + toast.error( + `${trimmedKey} is a reserved key. Please use the dedicated fields above.`, + ); + return; + } + + const exists = environmentVariables.some((env) => env.key === trimmedKey); + if (exists) { + toast.error('Environment variable key already exists'); + return; + } + + const newEnv: EnvironmentVariable = { + id: Date.now().toString(), + key: trimmedKey, + value: trimmedValue, + originalValue: trimmedValue, + isEdited: true, + }; + + setEnvironmentVariables((prev) => [...prev, newEnv]); + setNewEnvKey(''); + setNewEnvValue(''); + }, [newEnvKey, newEnvValue, environmentVariables]); + + const removeEnvironmentVariable = React.useCallback((id: string) => { + setEnvironmentVariables((prev) => prev.filter((env) => env.id !== id)); + }, []); + + const updateEnvironmentVariable = React.useCallback( + (id: string, key: string, value: string) => { + const uppercaseKey = key.toUpperCase(); + + if (RESERVED_KEYS.includes(uppercaseKey)) { + toast.error( + `${uppercaseKey} is a reserved key. Please use the dedicated fields.`, + ); + return; + } + + const exists = environmentVariables.some( + (env) => env.key === uppercaseKey && env.id !== id, ); + if (exists) { + toast.error('Environment variable key already exists'); + return; + } + + setEnvironmentVariables((prev) => + prev.map((env) => + env.id === id + ? { ...env, key: uppercaseKey, value, isEdited: true } + : env, + ), + ); + }, + [environmentVariables], + ); + + const handleEnvFocus = React.useCallback((id: string) => { + setEnvironmentVariables((prev) => + prev.map((env) => + env.id === id + ? { ...env, value: env.isEdited ? env.value : '', isEdited: true } + : env, + ), + ); + }, []); + + // Handler for reverting environment variable on blur if unchanged + const handleEnvBlur = React.useCallback((id: string) => { + setEnvironmentVariables((prev) => + prev.map((env) => { + if (env.id === id) { + // If value is empty or unchanged, revert to original + if (!env.value.trim() || env.value === env.originalValue) { + return { + ...env, + value: env.originalValue || '', + isEdited: false, + }; + } + } + return env; + }), + ); + }, []); + + // Handler for GitHub username focus + const handleGithubUsernameFocus = React.useCallback(() => { + if (!isGithubUsernameEdited) { + setGithubUsername(''); + setIsGithubUsernameEdited(true); } + }, [isGithubUsernameEdited]); + + // Handler for GitHub username blur + const handleGithubUsernameBlur = React.useCallback(() => { + if (!githubUsername.trim()) { + setGithubUsername(originalGithubUsername); + setIsGithubUsernameEdited(false); + } + }, [githubUsername, originalGithubUsername]); + + // Handler for GitHub password focus + const handleGithubPasswordFocus = React.useCallback(() => { + if (!isGithubPasswordEdited) { + setGithubPassword(''); + setIsGithubPasswordEdited(true); + } + }, [isGithubPasswordEdited]); + + // Handler for GitHub password blur + const handleGithubPasswordBlur = React.useCallback(() => { + if (!githubPassword.trim()) { + setGithubPassword(originalGithubPassword); + setIsGithubPasswordEdited(false); + } + }, [githubPassword, originalGithubPassword]); + + const buttonIcon = React.useMemo(() => { + if (isPushing) return ; + return ; + }, [isPushing]); + + const buttonText = React.useMemo(() => { + if (isPushing) return 'Running…'; + return 'Run on Cloud'; + }, [isPushing]); + + const renderLoadingSkeleton = () => ( + + + + + + ); + + const renderBlockingError = () => { + if (!blockingError) return null; + + return ( + + + {blockingError.title} + + {blockingError.message} + + ); + }; + + const renderLocalChangesWarning = () => { + if (!hasLocalChanges || !!blockingError) return null; + + return ( + + + Uncommitted Local Changes Detected + + + Your project has{' '} + {localChanges?.untrackedCount + ? `${localChanges.untrackedCount} untracked, ` + : ''} + {localChanges?.uncommittedCount + ? `${localChanges.uncommittedCount} uncommitted, ` + : ''} + {localChanges?.hasUnpushed + ? `${localChanges.unpushedCount} unpushed ` + : ''} + change(s). The cloud deployment will pull from the remote Git + repository and + will not include these local changes. + + + Please commit and push your changes before deploying to ensure the + cloud version matches your local environment. + + + ); + }; + + const renderDeploymentFields = () => { + if (!!blockingError || isLoading) return null; + + return ( + + setTitle(event.target.value)} + error={!!titleError} + helperText={titleError || 'Displayed on Rosetta Cloud dashboards.'} + fullWidth + required + sx={{ + '& .MuiOutlinedInput-root': { + bgcolor: alpha( + theme.palette.background.default, + theme.palette.mode === 'dark' ? 0.4 : 0.5, + ), + }, + }} + /> + + + + setGitBranch(event.target.value)} + helperText="Auto-filled from your current branch." + fullWidth + disabled + sx={{ + '& .MuiOutlinedInput-root': { + bgcolor: alpha( + theme.palette.background.default, + theme.palette.mode === 'dark' ? 0.4 : 0.5, + ), + }, + }} + /> + + + + + + setDbtArguments(event.target.value)} + placeholder="e.g., --select my_model --full-refresh" + fullWidth + multiline + sx={{ + '& .MuiOutlinedInput-root': { + bgcolor: alpha( + theme.palette.background.default, + theme.palette.mode === 'dark' ? 0.4 : 0.5, + ), + }, + '& .MuiInputBase-input': { + fontFamily: 'monospace', + fontSize: '0.875rem', + }, + }} + helperText="Optional: Add dbt arguments like --select, --exclude, --full-refresh, --vars, etc." + /> + + + + + + + Git Credentials + + + setGithubUsername(event.target.value)} + onFocus={handleGithubUsernameFocus} + onBlur={handleGithubUsernameBlur} + placeholder={ + !isGithubUsernameEdited && originalGithubUsername + ? '••••••••' + : '' + } + fullWidth + sx={{ + '& .MuiOutlinedInput-root': { + bgcolor: theme.palette.background.paper, + }, + }} + /> + + setGithubPassword(event.target.value)} + onFocus={handleGithubPasswordFocus} + onBlur={handleGithubPasswordBlur} + placeholder={ + !isGithubPasswordEdited && originalGithubPassword + ? '••••••••' + : '' + } + fullWidth + sx={{ + '& .MuiOutlinedInput-root': { + bgcolor: theme.palette.background.paper, + }, + }} + slotProps={{ + input: { + endAdornment: ( + + setShowGithubPassword((prev) => !prev)} + edge="end" + aria-label="Toggle GitHub credential visibility" + > + {showGithubPassword ? ( + + ) : ( + + )} + + + ), + }, + }} + /> + + + + ); }; - const disableSubmit = - !project?.id || - isLoadingKey || - isPushing || - !title.trim() || - !gitUrl.trim() || - !!urlError || - !!titleError || - !apiKey; + const renderEnvironmentVariables = () => { + if (!!blockingError || isLoading) return null; + + return ( + + } + sx={{ + borderRadius: 2, + minHeight: 56, + '&.Mui-expanded': { + minHeight: 56, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + borderBottom: `1px solid ${theme.palette.divider}`, + }, + '& .MuiAccordionSummary-content': { + alignItems: 'center', + gap: 1, + }, + }} + > + + + Environment Variables + + {environmentVariables.length > 0 && ( + + )} + + + + + {isRunMode + ? 'View existing environment variables for your deployed project. Click on a value to edit it.' + : 'Add custom environment variables for your project.'} + + + + + setNewEnvKey(e.target.value)} + placeholder="e.g., DBT_PROFILES_DIR" + sx={{ flex: 2 }} + /> + setNewEnvValue(e.target.value)} + placeholder="e.g., /app/profiles" + sx={{ flex: 3 }} + /> + + + + + + Note: ROSETTA_GIT_USER and ROSETTA_GIT_PASSWORD are reserved + keys. + + + + + {environmentVariables.length > 0 && ( + <> + {!isRunMode && } + + {!isRunMode && ( + + Added Variables + + )} + {environmentVariables.map((env) => ( + + + + updateEnvironmentVariable( + env.id, + e.target.value, + env.value, + ) + } + onFocus={() => handleEnvFocus(env.id)} + onBlur={() => handleEnvBlur(env.id)} + variant="outlined" + slotProps={{ + input: { + readOnly: isRunMode, + }, + }} + sx={{ + flex: 1, + '& .MuiInputBase-input': { + fontFamily: 'monospace', + fontSize: '0.875rem', + fontWeight: 600, + }, + }} + /> + + updateEnvironmentVariable( + env.id, + env.key, + e.target.value, + ) + } + onFocus={() => handleEnvFocus(env.id)} + onBlur={() => handleEnvBlur(env.id)} + variant="outlined" + placeholder={ + isRunMode && !env.isEdited ? '••••••••' : '' + } + sx={{ + flex: 2, + '& .MuiInputBase-input': { + fontFamily: 'monospace', + fontSize: '0.875rem', + }, + }} + /> + {!isRunMode && ( + removeEnvironmentVariable(env.id)} + sx={{ + color: 'error.main', + bgcolor: alpha(theme.palette.error.main, 0.08), + '&:hover': { + bgcolor: alpha(theme.palette.error.main, 0.15), + }, + }} + > + + + )} + {env.isEdited && ( + + )} + + + ))} + + + )} + + {/* Empty state for run mode with no secrets */} + {isRunMode && environmentVariables.length === 0 && ( + + + + No environment variables configured for this project. + + + )} + + + + ); + }; return ( = ({ onClose(); } }} - title="Deploy Project to Rosetta Cloud" + title="Run on Cloud" >
- + + {/* Status Badge */} + + {project?.externalId ? ( + } + label="Already Deployed" + color="success" + sx={{ + fontWeight: 600, + px: 0.5, + bgcolor: alpha(theme.palette.success.main, 0.1), + color: 'success.main', + border: `1px solid ${alpha(theme.palette.success.main, 0.3)}`, + }} + /> + ) : ( + } + label="Not Deployed" + color="warning" + sx={{ + fontWeight: 600, + px: 0.5, + bgcolor: alpha(theme.palette.warning.main, 0.1), + color: 'warning.main', + border: `1px solid ${alpha(theme.palette.warning.main, 0.3)}`, + }} + /> + )} + + - Ensure a Rosetta Cloud API key is configured in Settings before - deploying. Submissions use the workspace key stored securely on this - device. + Run your deployed project on the cloud. - {isLoadingKey && ( - Loading secure credentials… - )} + {isLoading && renderLoadingSkeleton()} + + {!isLoading && renderBlockingError()} - {!isLoadingKey && !apiKey && ( - - No cloud API key found. Add one in Settings → General → Cloud - Workspace first. + {!isLoading && renderLocalChangesWarning()} + + {formError && !blockingError && ( + + {formError} )} - {formError && {formError}} - - setTitle(event.target.value)} - error={!!titleError} - helperText={titleError || 'Displayed on Rosetta Cloud dashboards.'} - fullWidth - required - /> - - - - setGitBranch(event.target.value)} - helperText="Branch to deploy. Defaults to main." - fullWidth - InputProps={{ readOnly: true }} - /> - - setGithubUsername(event.target.value)} - helperText="Optional. Leave blank to use repository defaults." - fullWidth - /> - - setGithubPassword(event.target.value)} - helperText="Optional. Stored only for this submission." - fullWidth - InputProps={{ - endAdornment: ( - - setShowGithubPassword((prev) => !prev)} - edge="end" - aria-label="Toggle GitHub credential visibility" - > - {showGithubPassword ? : } - - - ), - }} - /> - + {!isLoading && renderDeploymentFields()} + + {!isLoading && renderEnvironmentVariables()} + + @@ -310,19 +1090,17 @@ export const PushToCloudModal: React.FC = ({ type="submit" variant="contained" color="primary" - disabled={disableSubmit} - startIcon={ - isPushing ? ( - - ) : ( - - ) - } + disabled={!canSubmit} + startIcon={buttonIcon} + sx={{ + minWidth: 140, + fontWeight: 600, + }} > - {isPushing ? 'Deploying…' : 'Deploy'} + {buttonText} - +
); diff --git a/src/renderer/components/profile/ProfileCard.tsx b/src/renderer/components/profile/ProfileCard.tsx new file mode 100644 index 00000000..4ecc0544 --- /dev/null +++ b/src/renderer/components/profile/ProfileCard.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { + Card, + CardContent, + Avatar, + Typography, + Chip, + Box, + CircularProgress, +} from '@mui/material'; +import { Person, AdminPanelSettings } from '@mui/icons-material'; +import { useProfile } from '../../controllers/profile.controller'; + +export const ProfileCard: React.FC = () => { + const { data: profile, isLoading, error } = useProfile(); + + if (isLoading) { + return ( + + + + + + + + ); + } + + if (error || !profile) { + return ( + + + + Profile information unavailable + + + + ); + } + + const getInitials = (name: string | null, email: string) => { + if (name) { + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase(); + } + return email[0].toUpperCase(); + }; + + return ( + + + + {getInitials(profile.name, profile.email)} + + {profile.name || 'User'} + + {profile.email} + + + : + } + label={profile.role} + size="small" + color={profile.role === 'ADMIN' ? 'primary' : 'default'} + /> + + + + + + ); +}; diff --git a/src/renderer/components/profile/index.ts b/src/renderer/components/profile/index.ts new file mode 100644 index 00000000..f92d8174 --- /dev/null +++ b/src/renderer/components/profile/index.ts @@ -0,0 +1 @@ +export { ProfileCard } from './ProfileCard'; diff --git a/src/renderer/components/settings/CloudSettings.tsx b/src/renderer/components/settings/CloudSettings.tsx new file mode 100644 index 00000000..59a985fb --- /dev/null +++ b/src/renderer/components/settings/CloudSettings.tsx @@ -0,0 +1,346 @@ +import React from 'react'; +import { + TextField, + Box, + Button, + Card, + CardContent, + CardActions, + Typography, + CircularProgress, + Alert, + IconButton, + InputAdornment, + Tooltip, +} from '@mui/material'; +import { + CloudOutlined, + DeleteOutline, + CloudDoneOutlined, + Login, + Visibility, + VisibilityOff, +} from '@mui/icons-material'; +import { toast } from 'react-toastify'; +import { useAuthLogin, useValidateApiKey, useApiKey } from '../../controllers'; +import useSecureStorage from '../../hooks/useSecureStorage'; +import { useApiKeySync } from '../../hooks/useApiKeySync'; + +export const CloudSettings: React.FC = () => { + const { setCloudApiKey, deleteCloudApiKey } = useSecureStorage(); + + // State for API key management + const [apiKeyInput, setApiKeyInput] = React.useState(''); + const [apiKeyError, setApiKeyError] = React.useState(''); + const [isSaving, setIsSaving] = React.useState(false); + const [showApiKey, setShowApiKey] = React.useState(false); + const [showCurrentApiKey, setShowCurrentApiKey] = React.useState(false); + + // Hooks + const { data: currentApiKey } = useApiKey(); + const { mutateAsync: validateApiKey, isLoading: isValidating } = + useValidateApiKey(); + const { mutate: login, isLoading: loginLoading } = useAuthLogin({ + onSuccess: () => { + toast.success( + 'Login initiated! Please complete authentication in your browser.', + ); + }, + onError: (error) => { + toast.error(`Login failed: ${error.message || 'Unknown error'}`); + }, + }); + + // Subscribe to authentication events (OAuth login/logout) + const { refreshAuthState } = useApiKeySync(); + + const hasApiKey = !!currentApiKey; + + // Listen for API key changes (OAuth login) and clear input + React.useEffect(() => { + if (hasApiKey) { + // API key was received (likely from OAuth), clear input + setApiKeyInput(''); + setApiKeyError(''); + } + }, [hasApiKey]); + + const handleApiKeyChange = (event: React.ChangeEvent) => { + setApiKeyInput(event.target.value); + // Clear any existing error when user starts typing + if (apiKeyError) { + setApiKeyError(''); + } + }; + + const handleSaveApiKey = async () => { + const apiKeyToSave = apiKeyInput.trim(); + + if (!apiKeyToSave) { + setApiKeyError('API key is required.'); + return; + } + + if (apiKeyToSave.length < 16) { + setApiKeyError('API key must be at least 16 characters.'); + return; + } + + setIsSaving(true); + setApiKeyError(''); + + try { + // Validate API key against server BEFORE saving + const validation = await validateApiKey(apiKeyToSave); + + if (!validation.valid) { + setApiKeyError(validation.error || 'Invalid API key'); + return; + } + + // Only save if validation passes + await setCloudApiKey(apiKeyToSave); + + // Refresh the API key query to get the updated value + await refreshAuthState(); + + setApiKeyInput(''); + + toast.success('API key saved successfully'); + } catch { + setApiKeyError('Failed to save API key. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + const handleRemoveApiKey = async () => { + setIsSaving(true); + try { + await deleteCloudApiKey(); + + // Refresh the API key query + await refreshAuthState(); + + setApiKeyInput(''); + setApiKeyError(''); + + toast.success('Cloud API key removed.'); + } catch { + toast.error('Unable to remove the cloud API key.'); + } finally { + setIsSaving(false); + } + }; + + const getApiKeyHelperText = () => { + if (apiKeyError) { + return apiKeyError; + } + if (hasApiKey) { + return 'To change your API key, first remove the current connection, then add a new one.'; + } + return 'Enter your API key from Rosetta Cloud or use the OAuth login above.'; + }; + + const canSaveApiKey = + !isSaving && + !isValidating && + !hasApiKey && // Only allow saving when no API key exists + apiKeyInput.trim().length >= 16 && + !apiKeyError; + + return ( + + + Cloud Dashboard Connection + + + Connect to your Rosetta Cloud Dashboard to enable cloud features like + project deployment and profile synchronization. + + + {/* OAuth Login Section - only show if no API key */} + {!hasApiKey && ( + + + Recommended: Use OAuth login for the best + experience + + + + )} + + {/* Connection Status */} + {hasApiKey && ( + + ✅ Connected to Cloud Dashboard + + )} + + {/* API Key Management Section */} + + + + + + API Key Management + + + + {/* Current API Key Display - only show when API key exists */} + {hasApiKey && ( + + + setShowCurrentApiKey(!showCurrentApiKey)} + edge="end" + size="small" + sx={{ padding: '4px' }} + > + {showCurrentApiKey ? ( + + ) : ( + + )} + + + + ), + }} + /> + )} + + {/* API Key Input - only show when no API key exists */} + {!hasApiKey && ( + + + setShowApiKey(!showApiKey)} + edge="end" + size="small" + sx={{ padding: '4px' }} + > + {showApiKey ? ( + + ) : ( + + )} + + + + ), + }} + /> + )} + + {/* Helper text for existing API key */} + {hasApiKey && ( + + {getApiKeyHelperText()} + + )} + + + + + + {/* Save button - only show when no API key exists */} + {!hasApiKey && ( + + )} + + + + ); +}; diff --git a/src/renderer/components/settings/ProfileSettings.tsx b/src/renderer/components/settings/ProfileSettings.tsx new file mode 100644 index 00000000..4dd1386c --- /dev/null +++ b/src/renderer/components/settings/ProfileSettings.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { + Box, + Button, + Typography, + Card, + CardContent, + CircularProgress, + Alert, +} from '@mui/material'; +import { Refresh, CloudOff } from '@mui/icons-material'; +import { + useApiKey, + useProfile, + useRefreshProfile, + useProfileSubscription, +} from '../../controllers'; +import { ProfileCard } from '../profile'; +import { CloudSettings } from './CloudSettings'; + +export const ProfileSettings: React.FC = () => { + const { data: apiKey, isLoading: apiKeyLoading } = useApiKey(); + const { isLoading: profileLoading, error: profileError } = useProfile(); + const { mutate: refreshProfile, isLoading: refreshing } = useRefreshProfile(); + + // Subscribe to profile events for real-time updates + useProfileSubscription(); + + const isLoading = apiKeyLoading || profileLoading; + + if (isLoading) { + return ( + + + + ); + } + + // Always show cloud settings, regardless of connection status + return ( + + + + {!apiKey && ( + + + Profile Information + + + + + + Not Connected + + + Connect to your Cloud Dashboard account above to view your + profile information. + + + + + )} + + {apiKey && ( + + + Profile Information + + + + + + Your profile information from the Cloud Dashboard. + + + + + {profileError && ( + + Profile data may be outdated. Last refresh failed. + + )} + + )} + + ); +}; diff --git a/src/renderer/components/settings/index.ts b/src/renderer/components/settings/index.ts index 16819a88..2e08cc0b 100644 --- a/src/renderer/components/settings/index.ts +++ b/src/renderer/components/settings/index.ts @@ -1,4 +1,5 @@ export * from './GeneralSettings'; +export * from './ProfileSettings'; export * from './AIProvidersSettings'; export * from './DbtSettings'; export * from './RosettaSettings'; diff --git a/src/renderer/config/constants.ts b/src/renderer/config/constants.ts index 732d065a..39ba715d 100644 --- a/src/renderer/config/constants.ts +++ b/src/renderer/config/constants.ts @@ -62,6 +62,8 @@ export const QUERY_KEYS = { GIT_STATUSES: 'GIT_STATUSES', GIT_STATUS: 'GIT_STATUS', GIT_DIFF: 'GIT_DIFF', + GIT_LOCAL_CHANGES: 'GIT_LOCAL_CHANGES', + GIT_REPO_INFO: 'GIT_REPO_INFO', GIT_AHEAD_BEHIND: 'GIT_AHEAD_BEHIND', GET_AI_PROVIDERS: 'GET_AI_PROVIDERS', GET_AI_PROVIDER_BY_ID: 'GET_AI_PROVIDER_BY_ID', @@ -85,6 +87,9 @@ export const QUERY_KEYS = { // Selected file context GET_SELECTED_FILE_CONTEXT: 'GET_SELECTED_FILE_CONTEXT', GET_FILE_METADATA: 'GET_FILE_METADATA', + API_KEY: 'API_KEY', + USER_PROFILE: 'USER_PROFILE', + CLOUD_SECRETS: 'CLOUD_SECRETS', }; export const AI_PROMPTS = { diff --git a/src/renderer/context/AppProvider.tsx b/src/renderer/context/AppProvider.tsx index 635f17d6..894218f7 100644 --- a/src/renderer/context/AppProvider.tsx +++ b/src/renderer/context/AppProvider.tsx @@ -1,7 +1,12 @@ import React from 'react'; import { AppContextType } from '../../types/frontend'; import { Splash } from '../components'; -import { useGetProjects, useGetSelectedProject } from '../controllers'; +import { + useGetProjects, + useGetSelectedProject, + useGetSettings, + useProfile, +} from '../controllers'; import { useGetActiveAIProvider } from '../controllers/aiProviders.controller'; import { Project, Table } from '../../types/backend'; import { projectsServices } from '../services'; @@ -30,12 +35,15 @@ export const AppContext = React.createContext({ setEditingFilePath: () => {}, syncEditorContent: () => {}, registerSyncEditorContent: () => {}, + env: 'local', }); const AppProvider: React.FC = ({ children }) => { const { data: projects = [] } = useGetProjects(); + const { data: settings } = useGetSettings(); const { data: selectedProject, isLoading } = useGetSelectedProject(); const { data: activeAIProvider } = useGetActiveAIProvider(); + const { data: profile } = useProfile(); const [isSidebarOpen, setIsSidebarOpen] = React.useState(true); const [isChatOpen, setIsChatOpen] = React.useState(false); @@ -158,6 +166,8 @@ const AppProvider: React.FC = ({ children }) => { setEditingFilePath, syncEditorContent, registerSyncEditorContent, + authenticatedUser: profile, + env: profile ? (settings?.env ?? 'local') : 'local', }; }, [ projects, @@ -174,6 +184,8 @@ const AppProvider: React.FC = ({ children }) => { editingFilePath, syncEditorContent, registerSyncEditorContent, + profile, + settings, ]); if (isLoading) { diff --git a/src/renderer/controllers/git.controller.ts b/src/renderer/controllers/git.controller.ts index a832ccc6..c9d71954 100644 --- a/src/renderer/controllers/git.controller.ts +++ b/src/renderer/controllers/git.controller.ts @@ -12,6 +12,8 @@ import { DiffResponse, FileStatus, GitBranch, + GitChangesRes, + RepoInfoRes, } from '../../types/backend'; import { QUERY_KEYS } from '../config/constants'; import { gitServices } from '../services'; @@ -112,6 +114,40 @@ export const useGetFileDiff = ( }); }; +export const useGetLocalChanges = ( + path: string, + customOptions?: UseQueryOptions< + GitChangesRes | null, + CustomError, + GitChangesRes | null + >, +) => { + return useQuery({ + queryKey: [QUERY_KEYS.GIT_LOCAL_CHANGES], + queryFn: async () => { + return gitServices.getLocalChanges(path); + }, + ...customOptions, + }); +}; + +export const useGetRepoInfo = ( + path: string, + customOptions?: UseQueryOptions< + RepoInfoRes | null, + CustomError, + RepoInfoRes | null + >, +) => { + return useQuery({ + queryKey: [QUERY_KEYS.GIT_REPO_INFO], + queryFn: async () => { + return gitServices.getRepoInfo(path); + }, + ...customOptions, + }); +}; + export const useGitInit = ( customOptions?: UseMutationOptions, ): UseMutationResult => { diff --git a/src/renderer/controllers/index.ts b/src/renderer/controllers/index.ts index 3e6e227e..cabafb42 100644 --- a/src/renderer/controllers/index.ts +++ b/src/renderer/controllers/index.ts @@ -5,5 +5,7 @@ export * from './git.controller'; export * from './update.controller'; export * from './cloudExplorer.controller'; export * from './utils.controller'; +export * from './profile.controller'; +export * from './rosettaCloud.controller'; export * from './duckLake.controller'; export * from './lineage.controller'; diff --git a/src/renderer/controllers/profile.controller.ts b/src/renderer/controllers/profile.controller.ts new file mode 100644 index 00000000..22d57c35 --- /dev/null +++ b/src/renderer/controllers/profile.controller.ts @@ -0,0 +1,112 @@ +import React from 'react'; +import { + useMutation, + UseMutationOptions, + UseMutationResult, + useQuery, + UseQueryOptions, + useQueryClient, +} from 'react-query'; +import { toast } from 'react-toastify'; +import type { CustomError } from '../../types/backend'; +import { UserProfile } from '../../types/profile'; +import { profileService } from '../services/profile.service'; + +export const PROFILE_QUERY_KEY = 'USER_PROFILE'; + +export const useProfile = ( + options?: UseQueryOptions< + UserProfile | null, + CustomError, + UserProfile | null + >, +) => { + return useQuery({ + queryKey: [PROFILE_QUERY_KEY], + queryFn: () => profileService.getProfile(), + staleTime: 5 * 60 * 1000, // 5 minutes + retry: (failureCount, error) => { + // Don't retry on auth errors + if (error?.message?.includes('401')) return false; + return failureCount < 3; + }, + ...options, + }); +}; + +export const useRefreshProfile = ( + options?: UseMutationOptions, +): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => profileService.refreshProfile(), + onSuccess: (profile) => { + queryClient.setQueryData([PROFILE_QUERY_KEY], profile); + if (profile) { + toast.success('Profile refreshed successfully'); + } + }, + onError: (error) => { + toast.error(`Failed to refresh profile: ${error.message}`); + }, + ...options, + }); +}; + +export const useProfileSubscription = () => { + const queryClient = useQueryClient(); + + // Listen for auth events to manage profile state + React.useEffect(() => { + const handleAuthSuccess = () => { + // Refresh profile when user logs in + queryClient.invalidateQueries({ queryKey: [PROFILE_QUERY_KEY] }); + }; + + const handleAuthError = () => { + // Clear profile on auth error + queryClient.setQueryData([PROFILE_QUERY_KEY], null); + }; + + const handleApiKeyUpdate = () => { + // Refresh profile when API key updates + queryClient.invalidateQueries({ queryKey: [PROFILE_QUERY_KEY] }); + }; + + const handleLogout = () => { + queryClient.setQueryData([PROFILE_QUERY_KEY], null); + }; + + // Subscribe to auth events + window.electron.ipcRenderer.on( + 'rosettaCloud:authSuccess', + handleAuthSuccess, + ); + window.electron.ipcRenderer.on('rosettaCloud:authError', handleAuthError); + window.electron.ipcRenderer.on( + 'rosettaCloud:apiKeyUpdated', + handleApiKeyUpdate, + ); + window.electron.ipcRenderer.on('rosettaCloud:logout', handleLogout); + + return () => { + window.electron.ipcRenderer.removeListener( + 'rosettaCloud:authSuccess', + handleAuthSuccess, + ); + window.electron.ipcRenderer.removeListener( + 'rosettaCloud:authError', + handleAuthError, + ); + window.electron.ipcRenderer.removeListener( + 'rosettaCloud:apiKeyUpdated', + handleApiKeyUpdate, + ); + window.electron.ipcRenderer.removeListener( + 'rosettaCloud:logout', + handleLogout, + ); + }; + }, [queryClient]); +}; diff --git a/src/renderer/controllers/projects.controller.ts b/src/renderer/controllers/projects.controller.ts index ad91742a..de49cea6 100644 --- a/src/renderer/controllers/projects.controller.ts +++ b/src/renderer/controllers/projects.controller.ts @@ -22,46 +22,6 @@ export const useGetProjects = ( }); }; -export const usePushProjectToCloud = ( - customOptions?: UseMutationOptions< - unknown, - CustomError, - { - title: string; - gitUrl: string; - gitBranch: string; - apiKey: string; - githubUsername?: string; - githubPassword?: string; - } - >, -): UseMutationResult< - unknown, - CustomError, - { - title: string; - gitUrl: string; - gitBranch: string; - apiKey: string; - githubUsername?: string; - githubPassword?: string; - } -> => { - const { onSuccess: onCustomSuccess, onError: onCustomError } = - customOptions || {}; - return useMutation({ - mutationFn: async (data) => { - return projectsServices.pushProjectToCloud(data); - }, - onSuccess: (...args) => { - onCustomSuccess?.(...args); - }, - onError: (...args) => { - onCustomError?.(...args); - }, - }); -}; - export const useGetSelectedProject = ( customOptions?: UseQueryOptions< Project | undefined, diff --git a/src/renderer/controllers/rosettaCloud.controller.ts b/src/renderer/controllers/rosettaCloud.controller.ts new file mode 100644 index 00000000..b39dd701 --- /dev/null +++ b/src/renderer/controllers/rosettaCloud.controller.ts @@ -0,0 +1,173 @@ +import { + useMutation, + UseMutationOptions, + UseMutationResult, + useQuery, + useQueryClient, + UseQueryOptions, +} from 'react-query'; +import React from 'react'; +import { toast } from 'react-toastify'; +import { + CloudDeploymentPayload, + CustomError, + Secret, +} from '../../types/backend'; +import { + ApiKeyState, + UseApiKeyResult, + UseAuthLoginResult, + UseAuthLogoutResult, +} from '../../types/apiKey'; +import { rosettaCloudServices } from '../services'; +import { QUERY_KEYS } from '../config/constants'; + +export const usePushProjectToCloud = ( + customOptions?: UseMutationOptions< + unknown, + CustomError, + CloudDeploymentPayload + >, +): UseMutationResult => { + const { onSuccess: onCustomSuccess, onError: onCustomError } = + customOptions || {}; + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + return rosettaCloudServices.pushProjectToCloud(data); + }, + onSuccess: async (...args) => { + await queryClient.invalidateQueries([QUERY_KEYS.GET_SELECTED_PROJECT]); + onCustomSuccess?.(...args); + }, + onError: (...args) => { + onCustomError?.(...args); + }, + }); +}; + +export const useApiKey = ( + options?: UseQueryOptions, +): UseApiKeyResult => { + const result = useQuery({ + queryKey: [QUERY_KEYS.API_KEY], + queryFn: () => rosettaCloudServices.getApiKey(), + ...options, + }); + + return { + data: result.data ?? null, + isLoading: result.isLoading, + error: result.error, + refetch: result.refetch, + }; +}; + +export const useValidateApiKey = () => { + return useMutation({ + mutationFn: (apiKey: string) => rosettaCloudServices.validateApiKey(apiKey), + retry: false, // Don't retry validation failures + }); +}; + +// Legacy hook removed as part of JWT token to API key migration +// Use useApiKey() instead + +export const useGetSecrets = ( + projectId?: string, + options?: UseQueryOptions, +) => { + return useQuery({ + queryKey: [QUERY_KEYS.CLOUD_SECRETS], + queryFn: () => rosettaCloudServices.getSecrets(projectId ?? ''), + ...options, + }); +}; + +export const useAuthLogin = ( + options?: UseMutationOptions, +): UseAuthLoginResult => { + const mutation = useMutation({ + mutationFn: () => rosettaCloudServices.openLogin(), + ...options, + }); + + return { + mutate: mutation.mutate, + isLoading: mutation.isLoading, + error: mutation.error, + }; +}; + +export const useAuthLogout = ( + options?: UseMutationOptions, +): UseAuthLogoutResult => { + const { onSuccess: onCustomSuccess, onError: onCustomError } = options || {}; + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: () => rosettaCloudServices.logout(), + onSuccess: async (...args) => { + // Invalidate both API key and profile queries + await queryClient.invalidateQueries([QUERY_KEYS.API_KEY]); + await queryClient.invalidateQueries([QUERY_KEYS.USER_PROFILE]); + + toast.success('Logged out successfully'); + onCustomSuccess?.(...args); + }, + onError: (error, ...args) => { + // eslint-disable-next-line no-console + console.error('Logout error:', error); + toast.error('Failed to logout'); + onCustomError?.(error as CustomError, ...args); + }, + }); + + return { + mutate: mutation.mutate, + isLoading: mutation.isLoading, + error: mutation.error, + }; +}; + +export const useAuthSubscription = () => { + const queryClient = useQueryClient(); + + React.useEffect(() => { + const unsubscribeSuccess = rosettaCloudServices.subscribeToAuthSuccess( + (payload) => { + // eslint-disable-next-line no-console + console.log('Auth success received:', payload); + toast.success('Cloud Dashboard login completed.'); + + // Invalidate queries to refresh data + queryClient.invalidateQueries([QUERY_KEYS.API_KEY]); + queryClient.invalidateQueries([QUERY_KEYS.USER_PROFILE]); + }, + ); + + const unsubscribeError = rosettaCloudServices.subscribeToAuthError( + (payload) => { + // eslint-disable-next-line no-console + console.error('Auth error received:', payload); + toast.error(payload.error || 'Authentication failed'); + }, + ); + + const unsubscribeApiKeyUpdate = + rosettaCloudServices.subscribeToApiKeyUpdate(() => { + // eslint-disable-next-line no-console + console.log('API key updated'); + + // Invalidate queries when API key is updated + queryClient.invalidateQueries([QUERY_KEYS.API_KEY]); + queryClient.invalidateQueries([QUERY_KEYS.USER_PROFILE]); + }); + + return () => { + unsubscribeSuccess(); + unsubscribeError(); + unsubscribeApiKeyUpdate(); + }; + }, [queryClient]); +}; diff --git a/src/renderer/hooks/useApiKeySync.ts b/src/renderer/hooks/useApiKeySync.ts new file mode 100644 index 00000000..42f7045d --- /dev/null +++ b/src/renderer/hooks/useApiKeySync.ts @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { useQueryClient } from 'react-query'; +import { useAuthSubscription } from '../controllers'; +import { QUERY_KEYS } from '../config/constants'; + +/** + * Simplified hook for refreshing authentication state + * Handles OAuth login events and provides a way to refresh auth queries + */ +export const useApiKeySync = () => { + const queryClient = useQueryClient(); + + // Subscribe to auth events (OAuth login/logout) + useAuthSubscription(); + + // Function to refresh global auth state + const refreshAuthState = useCallback(async () => { + await queryClient.invalidateQueries([QUERY_KEYS.API_KEY]); + await queryClient.invalidateQueries([QUERY_KEYS.USER_PROFILE]); + }, [queryClient]); + + return { + refreshAuthState, + }; +}; diff --git a/src/renderer/hooks/useDbt.ts b/src/renderer/hooks/useDbt.ts index 2b9b75f4..800eb9a8 100644 --- a/src/renderer/hooks/useDbt.ts +++ b/src/renderer/hooks/useDbt.ts @@ -8,6 +8,7 @@ import { useSetConnectionEnvVariable, } from '../controllers'; import { Project, DbtCommandType } from '../../types/backend'; +import { useAppContext } from './index'; interface UseDbtReturn { run: (project: Project, path?: string) => Promise; @@ -78,8 +79,12 @@ const extractCliErrorDetails = ( return Array.from(details); }; -const useDbt = (successCallback?: () => void): UseDbtReturn => { +const useDbt = ( + successCallback?: () => void, + cloudRunCb?: (command: DbtCommandType) => void, +): UseDbtReturn => { const { data: settings } = useGetSettings(); + const { env } = useAppContext(); const { runCommand, stopCommand, isRunning } = useCli(); const { data: connections = [] } = useGetConnections(); const { @@ -219,6 +224,11 @@ const useDbt = (successCallback?: () => void): UseDbtReturn => { setActiveCommand(command); + if (env === 'cloud') { + cloudRunCb?.(command); + return; + } + // Setup environment variables await setupConnectionEnv(connection.connection.name); diff --git a/src/renderer/hooks/useSecureStorage.ts b/src/renderer/hooks/useSecureStorage.ts index 4822fac5..c10634e0 100644 --- a/src/renderer/hooks/useSecureStorage.ts +++ b/src/renderer/hooks/useSecureStorage.ts @@ -1,4 +1,5 @@ import { secureStorageService } from '../services/secureStorage.service'; +import { CLOUD_DASHBOARD_API_KEY } from '../../main/utils/constants'; const useSecureStorage = () => { const setOpenAIKey = async (apiKey: string): Promise => { @@ -146,15 +147,15 @@ const useSecureStorage = () => { }; const setCloudApiKey = async (apiKey: string): Promise => { - await secureStorageService.set('cloud-api-key', apiKey); + await secureStorageService.set(CLOUD_DASHBOARD_API_KEY, apiKey); }; const getCloudApiKey = async (): Promise => { - return secureStorageService.get('cloud-api-key'); + return secureStorageService.get(CLOUD_DASHBOARD_API_KEY); }; const deleteCloudApiKey = async (): Promise => { - await secureStorageService.delete('cloud-api-key'); + await secureStorageService.delete(CLOUD_DASHBOARD_API_KEY); }; return { diff --git a/src/renderer/screens/projectDetails/index.tsx b/src/renderer/screens/projectDetails/index.tsx index cd40a3ed..d7c9d07d 100644 --- a/src/renderer/screens/projectDetails/index.tsx +++ b/src/renderer/screens/projectDetails/index.tsx @@ -30,7 +30,6 @@ import { TerminalLayout, BusinessModal, AiPromptModal, - PushToCloudModal, } from '../../components'; // import { ProjectSidebar } from '../../components/sidebar/project-sidebar'; import { ProjectSidebar } from '../../components/sidebar/project-sidebar'; @@ -133,7 +132,6 @@ const ProjectDetails: React.FC = () => { React.useState(null); const [aiTransformationResponse, setAitTransformationResponse] = React.useState(); - const [isPushModalOpen, setIsPushModalOpen] = React.useState(false); const [isSynchronizing, setIsSynchronizing] = React.useState(false); const { @@ -856,6 +854,7 @@ const ProjectDetails: React.FC = () => { fileContent={fileContent} isRunningDbt={isRunningDbt} isRunningRosettaDbt={isRunningRosettaDbt} + environment={settings?.env} /> )} { isRunningDbt={isRunningDbt} isRunningRosettaDbt={isRunningRosettaDbt} connection={connection} + environment={settings?.env} rosettaDbt={rosettaDbt} handleBusinessLayerClick={handleBusinessLayerClick} - onRunOnCloudClick={() => setIsPushModalOpen(true)} /> {connection?.id ? ( <> @@ -987,13 +986,6 @@ const ProjectDetails: React.FC = () => { onClose={() => setNoAiSetModal(false)} /> )} - { - setIsPushModalOpen(false); - }} - project={project} - /> {aiTransformationPrompt && ( { const getSectionTitle = (section: string) => { if (section === 'dbt') return 'dbt™ Core'; if (section === 'ai-providers') return 'AI Providers'; + if (section === 'profile') return 'Rosetta Cloud'; if (section === 'duckdb') return 'DuckDB'; return section.charAt(0).toUpperCase() + section.slice(1).replace('-', ' '); }; @@ -122,6 +124,8 @@ const Settings: React.FC = () => { onFilePicker={handleFilePicker} /> ); + case 'profile': + return ; case 'duckdb': return ; case 'ai-providers': diff --git a/src/renderer/screens/settings/settingsElements.tsx b/src/renderer/screens/settings/settingsElements.tsx index fcfcc477..21a12b13 100644 --- a/src/renderer/screens/settings/settingsElements.tsx +++ b/src/renderer/screens/settings/settingsElements.tsx @@ -1,6 +1,7 @@ import FolderIcon from '@mui/icons-material/Folder'; import PsychologyIcon from '@mui/icons-material/Psychology'; import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; +import CloudIcon from '@mui/icons-material/Cloud'; import InfoIcon from '@mui/icons-material/Info'; import { SvgIconComponent } from '@mui/icons-material'; import React from 'react'; @@ -65,6 +66,11 @@ export const settingsSidebarElements: SettingsSidebarElement[] = [ text: 'DuckDB', path: '/app/settings/duckdb', }, + { + icon: CloudIcon, + text: 'Rosetta Cloud', + path: '/app/settings/profile', + }, { icon: InfoIcon, text: 'About', diff --git a/src/renderer/services/git.service.ts b/src/renderer/services/git.service.ts index 059200e9..957ba67d 100644 --- a/src/renderer/services/git.service.ts +++ b/src/renderer/services/git.service.ts @@ -5,7 +5,9 @@ import { DiffResponse, FileStatus, GitBranch, + GitChangesRes, GitCredentials, + RepoInfoRes, RosettaConnection, } from '../../types/backend'; @@ -137,6 +139,22 @@ export const getFileStatus = async (repoPath: string, filePath: string) => { return data; }; +export const getLocalChanges = async (repoPath: string) => { + const { data } = await client.post< + { repoPath: string }, + GitChangesRes | null + >('git:getLocalChanges', { repoPath }); + return data; +}; + +export const getRepoInfo = async (repoPath: string) => { + const { data } = await client.post<{ repoPath: string }, RepoInfoRes | null>( + 'git:repoInfo', + { repoPath }, + ); + return data; +}; + export const unstage = async (repoPath: string, files: string[]) => { const { data } = await client.post< { repoPath: string; files: string[] }, diff --git a/src/renderer/services/index.ts b/src/renderer/services/index.ts index 01d6cf54..2965b96e 100644 --- a/src/renderer/services/index.ts +++ b/src/renderer/services/index.ts @@ -7,6 +7,7 @@ import * as secureStorageService from './secureStorage.service'; import * as utilsService from './utils.service'; import cloudExplorerService from './cloudExplorer.service'; import { connectionStorage } from './connectionStorage.service'; +import * as rosettaCloudServices from './rosettaCloud.service'; import { DuckLakeService } from './duckLake.service'; import * as lineageService from './lineage.service'; @@ -20,6 +21,7 @@ export { cloudExplorerService, connectionStorage, utilsService, + rosettaCloudServices, DuckLakeService, lineageService, }; diff --git a/src/renderer/services/profile.service.ts b/src/renderer/services/profile.service.ts new file mode 100644 index 00000000..8bc205f9 --- /dev/null +++ b/src/renderer/services/profile.service.ts @@ -0,0 +1,31 @@ +import { client } from '../config/client'; +import { UserProfile } from '../../types/profile'; + +const getProfile = async (): Promise => { + const { data } = await client.get( + 'rosettaCloud:getProfile', + ); + return data; +}; + +const refreshProfile = async (): Promise => { + const { data } = await client.get( + 'rosettaCloud:refreshProfile', + ); + return data; +}; + +const getCachedProfile = async (): Promise => { + const { data } = await client.get( + 'rosettaCloud:getCachedProfile', + ); + return data; +}; + +export const profileService = { + getProfile, + refreshProfile, + getCachedProfile, +}; + +export default profileService; diff --git a/src/renderer/services/projects.service.ts b/src/renderer/services/projects.service.ts index cfa32aa2..72387555 100644 --- a/src/renderer/services/projects.service.ts +++ b/src/renderer/services/projects.service.ts @@ -6,7 +6,6 @@ import { Project, Table, EnhanceModelResponseType, - CloudDeploymentPayload, } from '../../types/backend'; export const getProjects = async (): Promise => { @@ -301,9 +300,3 @@ export const downloadSeed = async ( project, }); }; - -export const pushProjectToCloud = async ( - body: CloudDeploymentPayload, -): Promise => { - await client.post('project:pushToCloud', body); -}; diff --git a/src/renderer/services/rosettaCloud.service.ts b/src/renderer/services/rosettaCloud.service.ts new file mode 100644 index 00000000..35a0b5ca --- /dev/null +++ b/src/renderer/services/rosettaCloud.service.ts @@ -0,0 +1,112 @@ +import { client } from '../config/client'; +import { CloudDeploymentPayload, Secret } from '../../types/backend'; +import { AuthSuccessPayload, AuthErrorPayload } from '../../types/apiKey'; + +export const openLogin = async (): Promise => { + const { data } = await client.post( + 'rosettaCloud:login', + undefined, + ); + return data; +}; + +export const getApiKey = async (): Promise => { + const { data } = await client.get('rosettaCloud:getApiKey'); + return data; +}; + +export const logout = async (): Promise => { + await client.post('rosettaCloud:logout', undefined); +}; + +export const storeApiKey = async (apiKey: string): Promise => { + await client.post('rosettaCloud:storeApiKey', apiKey); +}; + +export const validateApiKey = async ( + apiKey: string, +): Promise<{ valid: boolean; error?: string }> => { + const { data } = await client.post< + string, + { valid: boolean; error?: string } + >('rosettaCloud:validateApiKey', apiKey); + return data; +}; + +export const subscribeToAuthSuccess = ( + callback: (payload: AuthSuccessPayload) => void, +) => { + const listener: (...args: unknown[]) => void = (_event, payload) => { + const data = (payload ?? {}) as Partial; + if (!data.apiKey) { + return; + } + callback({ apiKey: data.apiKey }); + }; + + window.electron.ipcRenderer.on('rosettaCloud:authSuccess', listener); + + return () => { + window.electron.ipcRenderer.removeListener( + 'rosettaCloud:authSuccess', + listener, + ); + }; +}; + +export const subscribeToAuthError = ( + callback: (payload: AuthErrorPayload) => void, +) => { + const listener: (...args: unknown[]) => void = (_event, payload) => { + const data = (payload ?? {}) as Partial; + callback({ error: data.error ?? 'Authentication failed.' }); + }; + + window.electron.ipcRenderer.on('rosettaCloud:authError', listener); + + return () => { + window.electron.ipcRenderer.removeListener( + 'rosettaCloud:authError', + listener, + ); + }; +}; + +export const subscribeToApiKeyUpdate = (callback: () => void) => { + const listener: (...args: unknown[]) => void = () => { + callback(); + }; + + window.electron.ipcRenderer.on('rosettaCloud:apiKeyUpdated', listener); + + return () => { + window.electron.ipcRenderer.removeListener( + 'rosettaCloud:apiKeyUpdated', + listener, + ); + }; +}; + +export const pushProjectToCloud = async ( + body: CloudDeploymentPayload, +): Promise => { + await client.post('rosettaCloud:push', body); +}; + +export const getSecrets = async (projectId: string): Promise => { + const { data } = await client.post( + 'rosettaCloud:getSecrets', + projectId, + ); + return data; +}; + +export const deleteSecret = async ( + projectId: string, + secretId: string, +): Promise => { + await client.post<{ projectId: string; secretId: string }, void>( + 'rosettaCloud:deleteSecret', + { projectId, secretId }, + ); +}; diff --git a/src/types/apiKey.ts b/src/types/apiKey.ts new file mode 100644 index 00000000..79413d41 --- /dev/null +++ b/src/types/apiKey.ts @@ -0,0 +1,73 @@ +/** + * API Key Authentication Types + * + * This file contains type definitions for API key-based authentication + * replacing the previous JWT token system. + */ + +// API Key Authentication State +export type ApiKeyState = string | null; + +// Authentication Event Payloads +export interface AuthSuccessPayload { + apiKey: string; +} + +export interface AuthErrorPayload { + error: string; +} + +export interface ApiKeyUpdatePayload { + // Void type - no payload data needed for API key updates +} + +// Authentication Status +export interface AuthenticationStatus { + isAuthenticated: boolean; + apiKey: ApiKeyState; + isLoading: boolean; + error?: string; +} + +// API Key Service Operations +export interface ApiKeyServiceOperations { + // Core operations + getApiKey(): Promise; + storeApiKey(apiKey: string): Promise; + clearApiKey(): Promise; + + // Authentication state + isAuthenticated(): Promise; + + // Event subscriptions + subscribeToAuthSuccess( + callback: (payload: AuthSuccessPayload) => void, + ): () => void; + subscribeToAuthError( + callback: (payload: AuthErrorPayload) => void, + ): () => void; + subscribeToApiKeyUpdate(callback: () => void): () => void; +} + +// React Query Hook Types +export interface UseApiKeyResult { + data: ApiKeyState; + isLoading: boolean; + error: unknown; + refetch: () => void; +} + +export interface UseAuthLoginResult { + mutate: () => void; + isLoading: boolean; + error: unknown; +} + +export interface UseAuthLogoutResult { + mutate: () => void; + isLoading: boolean; + error: unknown; +} + +// Legacy Types (deprecated - use API key types instead) +// Note: Legacy types removed as part of JWT token to API key migration diff --git a/src/types/backend.ts b/src/types/backend.ts index 29cc2ec6..74ec37eb 100644 --- a/src/types/backend.ts +++ b/src/types/backend.ts @@ -221,15 +221,19 @@ export type Project = { incrementalDir?: string; businessDir?: string; createTemplateFolders?: boolean; + externalId?: string; + lastRun?: string; }; export type CloudDeploymentPayload = { + id: string; title: string; gitUrl: string; gitBranch: string; - apiKey: string; githubUsername?: string; githubPassword?: string; + secrets: Record; + CUSTOM_DBT_COMMANDS?: string; }; export type DuckDBStatus = @@ -296,6 +300,8 @@ export type SettingsType = { mainDatabaseSize?: string | number; sqliteVersion?: string; mainDatabaseStatus?: 'connected' | 'disconnected' | 'error'; + + env?: 'local' | 'cloud'; // DuckDB metadata (read-only) duckdbPath?: string; duckdbSize?: string | number; @@ -751,3 +757,24 @@ export type Command = arguments: Map; options?: Map; }; + +export type GitChangesRes = { + hasUntracked: boolean; + hasUncommitted: boolean; + hasUnpushed: boolean; + untrackedCount: number; + uncommittedCount: number; + unpushedCount: number; +}; + +export type RepoInfoRes = { + remoteUrl: string | null; + currentBranch: string; + branchExistsOnRemote: boolean; +}; + +export type Secret = { + id: string; + name: string; + value: string; +}; diff --git a/src/types/frontend.ts b/src/types/frontend.ts index c2d0b757..fe802895 100644 --- a/src/types/frontend.ts +++ b/src/types/frontend.ts @@ -1,6 +1,7 @@ import { ReactNode } from 'react'; import type * as Monaco from 'monaco-editor'; import { Project, QueryResponseType, Table } from './backend'; +import { UserProfile } from './profile'; export type AppContextType = { projects: Project[]; @@ -25,6 +26,8 @@ export type AppContextType = { registerSyncEditorContent?: ( handler?: (path: string, content: string) => void, ) => void; + authenticatedUser?: UserProfile | null; + env: 'local' | 'cloud'; }; export type ItemProps = { diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 64320d15..86a2e486 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -51,8 +51,24 @@ export type ProjectChannels = | 'project:getQuery' | 'project:chooseDir' | 'project:renamePath' - | 'project:downloadSeed' - | 'project:pushToCloud'; + | 'project:downloadSeed'; + +export type RosettaCloudChannels = + | 'rosettaCloud:push' + | 'rosettaCloud:getProfile' + | 'rosettaCloud:refreshProfile' + | 'rosettaCloud:getCachedProfile' + | 'rosettaCloud:login' + | 'rosettaCloud:logout' + | 'rosettaCloud:getApiKey' + | 'rosettaCloud:storeApiKey' + | 'rosettaCloud:validateApiKey' + | 'rosettaCloud:authSuccess' + | 'rosettaCloud:authError' + | 'rosettaCloud:apiKeyUpdated' + | 'rosettaCloud:getSecrets' + | 'rosettaCloud:deleteSecret'; + export type ConnectorChannels = | 'connector:configure' | 'connector:remove' @@ -191,6 +207,8 @@ export type GitChannels = | 'git:checkout' | 'git:fileDiff' | 'git:fileStatusList' + | 'git:getLocalChanges' + | 'git:repoInfo' | 'git:fileStatus' | 'git:unstage' | 'git:stageAll' @@ -311,6 +329,7 @@ export type Channels = | UpdateChannels | CloudExplorerChannels | SourcesChannels + | RosettaCloudChannels | AIChannels | DuckLakeChannels | LineageChannels; diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 00000000..bf2dcb6d --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,31 @@ +export interface ProfilePreferences { + theme: 'light' | 'dark' | 'system'; + notifications: boolean; + timezone: string; + emailNotifications: boolean; + smsNotifications: boolean; + marketingEmails: boolean; + pushNotifications: boolean; +} + +export interface UserProfile { + id: string; + name: string | null; + email: string; + role: 'ADMIN' | 'USER'; + emailVerified: Date | null; + createdAt: Date; + updatedAt: Date; + phone: string | null; + avatar: string | null; + preferences: ProfilePreferences; +} + +export interface ProfileResponse { + profile: UserProfile; +} + +export interface ProfileError { + error: string; + code?: string; +}