diff --git a/apps/remix-ide/src/app.ts b/apps/remix-ide/src/app.ts index 3897896dfc0..0194869b220 100644 --- a/apps/remix-ide/src/app.ts +++ b/apps/remix-ide/src/app.ts @@ -49,6 +49,7 @@ import { EnvironmentExplorer } from './app/providers/environment-explorer' import { FileDecorator } from './app/plugins/file-decorator' import { CodeFormat } from './app/plugins/code-format' import { CompilationDetailsPlugin } from './app/plugins/compile-details' +import { AuthPlugin } from './app/plugins/auth-plugin' import { RemixGuidePlugin } from './app/plugins/remixGuide' import { TemplatesPlugin } from './app/plugins/remix-templates' import { fsPlugin } from './app/plugins/electron/fsPlugin' @@ -85,6 +86,8 @@ import { QueryParams } from '@remix-project/remix-lib' import { SearchPlugin } from './app/tabs/search' import { ScriptRunnerBridgePlugin } from './app/plugins/script-runner-bridge' import { ElectronProvider } from './app/files/electronProvider' +import { IframePlugin } from '@remixproject/engine-web' +import { endpointUrls } from '@remix-endpoints-helper' const Storage = remixLib.Storage import RemixDProvider from './app/files/remixDProvider' @@ -160,6 +163,7 @@ class AppComponent { topBar: Topbar templateExplorerModal: TemplateExplorerModalPlugin settings: SettingsTab + authPlugin: AuthPlugin params: any desktopClientMode: boolean @@ -303,6 +307,17 @@ class AppComponent { //---------------- Script Runner UI Plugin ------------------------- const scriptRunnerUI = new ScriptRunnerBridgePlugin(this.engine) + //---------------- SSO Plugin (Hidden Iframe) ------------------------- + const ssoPlugin = new IframePlugin({ + name: 'sso', + displayName: 'SSO Authentication', + url: `${endpointUrls.ssoPlugin}?ideOrigin=${encodeURIComponent(window.location.origin)}`, + location: 'hiddenPanel', + description: 'Manages authentication with OIDC providers and SIWE', + methods: ['login', 'logout', 'getUser', 'getToken', 'isAuthenticated', 'handlePopupResult'], + events: ['authStateChanged', 'tokenRefreshed', 'loginSuccess', 'logoutSuccess', 'openWindow', 'loginError'] + }) + //---- templates const templates = new TemplatesPlugin() @@ -560,6 +575,8 @@ class AppComponent { contentImport ) + this.authPlugin = new AuthPlugin() + this.engine.register([ compileTab, run, @@ -571,7 +588,9 @@ class AppComponent { linkLibraries, deployLibraries, openZeppelinProxy, - run.recorder + run.recorder, + this.authPlugin, + ssoPlugin ]) this.engine.register([templateExplorerModal, this.topBar]) @@ -636,6 +655,9 @@ class AppComponent { 'remixAI', 'remixaiassistant' ]) + // Activate SSO plugin first, then Auth plugin (Auth depends on SSO) + await this.appManager.activatePlugin(['sso']) + await this.appManager.activatePlugin(['auth']) await this.appManager.activatePlugin(['settings']) await this.appManager.activatePlugin(['walkthrough', 'storage', 'search', 'compileAndRun', 'recorder', 'dgitApi', 'dgit']) diff --git a/apps/remix-ide/src/app/plugins/auth-plugin.tsx b/apps/remix-ide/src/app/plugins/auth-plugin.tsx new file mode 100644 index 00000000000..0ac607cc76e --- /dev/null +++ b/apps/remix-ide/src/app/plugins/auth-plugin.tsx @@ -0,0 +1,226 @@ +import { Plugin } from '@remixproject/engine' +import { AuthUser, AuthProvider as AuthProviderType } from '@remix-api' + +export interface Credits { + balance: number + free_credits: number + paid_credits: number +} + +const profile = { + name: 'auth', + displayName: 'Authentication', + description: 'Handles SSO authentication and credits', + methods: ['login', 'logout', 'getUser', 'getCredits', 'refreshCredits'], + events: ['authStateChanged', 'creditsUpdated'] +} + +export class AuthPlugin extends Plugin { + constructor() { + super(profile) + } + + async login(provider: AuthProviderType): Promise { + try { + await this.call('sso', 'login', provider) + } catch (error) { + console.error('[AuthPlugin] Login failed:', error) + throw error + } + } + + async logout(): Promise { + try { + await this.call('sso', 'logout') + } catch (error) { + console.error('[AuthPlugin] Logout failed:', error) + } + } + + async getUser(): Promise { + try { + return await this.call('sso', 'getUser') + } catch (error) { + console.error('[AuthPlugin] Get user failed:', error) + return null + } + } + + async isAuthenticated(): Promise { + try { + return await this.call('sso', 'isAuthenticated') + } catch (error) { + return false + } + } + + async getToken(): Promise { + try { + return await this.call('sso', 'getToken') + } catch (error) { + return null + } + } + + async getCredits(): Promise { + const baseUrl = window.location.hostname.includes('localhost') + ? 'http://localhost:3000' + : 'https://endpoints-remix-dev.ngrok.dev' + + try { + const response = await fetch(`${baseUrl}/credits/balance`, { + method: 'GET', + credentials: 'include', + headers: { 'Accept': 'application/json' } + }) + + if (response.ok) { + return await response.json() + } + return null + } catch (error) { + console.error('[AuthPlugin] Failed to fetch credits:', error) + return null + } + } + + async refreshCredits(): Promise { + const credits = await this.getCredits() + if (credits) { + this.emit('creditsUpdated', credits) + } + return credits + } + + onActivation(): void { + console.log('[AuthPlugin] Activated') + + // Debug: Log queue status + setInterval(() => { + if ((this as any).queue && (this as any).queue.length > 0) { + console.log('[AuthPlugin] Queue:', (this as any).queue) + } + }, 2000) + + // Listen to SSO plugin events and forward them + this.on('sso', 'authStateChanged', (authState: any) => { + console.log('[AuthPlugin] authStateChanged received:', authState) + this.emit('authStateChanged', authState) + // Auto-refresh credits on auth change + if (authState.isAuthenticated) { + this.refreshCredits().catch(console.error) + } + }) + + this.on('sso', 'loginSuccess', (data: any) => { + console.log('[AuthPlugin] loginSuccess received:', data) + this.emit('authStateChanged', { + isAuthenticated: true, + user: data.user, + token: null + }) + this.refreshCredits().catch(console.error) + }) + + this.on('sso', 'loginError', (data: any) => { + console.log('[AuthPlugin] loginError received:', data) + this.emit('authStateChanged', { + isAuthenticated: false, + user: null, + token: null, + error: data.error + }) + }) + + this.on('sso', 'logout', () => { + console.log('[AuthPlugin] logout received') + this.emit('authStateChanged', { + isAuthenticated: false, + user: null, + token: null + }) + }) + + // Handle popup opening from SSO plugin + this.on('sso', 'openWindow', ({ url, id }: { url: string; id: string }) => { + console.log('[AuthPlugin] openWindow received:', url, id) + const width = 600 + const height = 700 + const left = window.screen.width / 2 - width / 2 + const top = window.screen.height / 2 - height / 2 + + const popup = window.open( + url, + 'sso-auth', + `width=${width},height=${height},left=${left},top=${top},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes` + ) + + if (!popup) { + console.error('[AuthPlugin] Popup blocked by browser') + this.call('sso', 'handlePopupResult', { + id, + success: false, + error: 'Popup blocked by browser' + }).catch(console.error) + return + } + + // Listen for auth result from popup + const messageHandler = (event: MessageEvent) => { + console.log('[AuthPlugin] Message received:', event.data, 'from origin:', event.origin) + const { type, requestId, user, accessToken, error } = event.data + + if (type === 'sso-auth-result' && requestId === id) { + console.log('[AuthPlugin] Auth result matched, closing popup') + cleanup() + + try { + if (popup && !popup.closed) popup.close() + } catch (e) {} + + console.log('[AuthPlugin] Calling handlePopupResult with:', {id, success: !error, user, accessToken, error}) + this.call('sso', 'handlePopupResult', { + id, + success: !error, + user, + accessToken, + error + }).then(() => { + console.log('[AuthPlugin] handlePopupResult call succeeded') + }).catch((err) => { + console.error('[AuthPlugin] handlePopupResult call failed:', err) + }) + } + } + + // Poll for popup closure + let pollAttempts = 0 + const maxPollAttempts = 600 + const pollInterval = setInterval(() => { + pollAttempts++ + + try { + if (popup.closed) { + cleanup() + this.call('sso', 'handlePopupResult', { + id, + success: false, + error: 'Login cancelled - popup closed' + }).catch(console.error) + } + } catch (e) {} + + if (pollAttempts >= maxPollAttempts) { + cleanup() + } + }, 1000) + + const cleanup = () => { + clearInterval(pollInterval) + window.removeEventListener('message', messageHandler) + } + + window.addEventListener('message', messageHandler) + }) + } +} diff --git a/apps/remix-ide/src/app/utils/AppRenderer.tsx b/apps/remix-ide/src/app/utils/AppRenderer.tsx index 004a9d61308..3e1383766c9 100644 --- a/apps/remix-ide/src/app/utils/AppRenderer.tsx +++ b/apps/remix-ide/src/app/utils/AppRenderer.tsx @@ -9,6 +9,7 @@ import { createRoot, Root } from 'react-dom/client'; import { TrackingProvider } from '../contexts/TrackingContext'; import { Preload } from '../components/preload'; import { GitHubPopupCallback } from '../pages/GitHubPopupCallback'; +// Popup/standalone subscription pages removed; overlay/inline handled in main UI import { TrackingFunction } from './TrackingFunction'; export interface RenderAppOptions { diff --git a/apps/remix-ide/src/remixAppManager.ts b/apps/remix-ide/src/remixAppManager.ts index 498d2fe818b..038bdc2666c 100644 --- a/apps/remix-ide/src/remixAppManager.ts +++ b/apps/remix-ide/src/remixAppManager.ts @@ -95,7 +95,9 @@ let requiredModules = [ 'topbar', 'templateexplorermodal', 'githubAuthHandler', - 'desktopClient' + 'desktopClient', + 'sso', + 'auth' ] // dependentModules shouldn't be manually activated (e.g hardhat is activated by remixd) diff --git a/apps/remix-ide/src/remixEngine.js b/apps/remix-ide/src/remixEngine.js index ba26ef02470..48dd6b7c88e 100644 --- a/apps/remix-ide/src/remixEngine.js +++ b/apps/remix-ide/src/remixEngine.js @@ -32,6 +32,7 @@ export class RemixEngine extends Engine { if (name === 'contentImport') return { queueTimeout: 60000 * 3 } if (name === 'circom') return { queueTimeout: 60000 * 4 } if (name === 'noir-compiler') return { queueTimeout: 60000 * 4 } + if (name === 'sso') return { queueTimeout: 60000 * 4 } return { queueTimeout: 10000 } } diff --git a/apps/remix-ide/webpack.config.js b/apps/remix-ide/webpack.config.js index 97ea0b49279..5df4eee04ad 100644 --- a/apps/remix-ide/webpack.config.js +++ b/apps/remix-ide/webpack.config.js @@ -201,7 +201,8 @@ module.exports = composePlugins(withNx(), withReact(), (config) => { // set the define plugin to load the WALLET_CONNECT_PROJECT_ID config.plugins.push( new webpack.DefinePlugin({ - WALLET_CONNECT_PROJECT_ID: JSON.stringify(process.env.WALLET_CONNECT_PROJECT_ID) + WALLET_CONNECT_PROJECT_ID: JSON.stringify(process.env.WALLET_CONNECT_PROJECT_ID), + 'process.env.NX_ENDPOINTS_URL': JSON.stringify(process.env.NX_ENDPOINTS_URL) }) ) @@ -244,6 +245,20 @@ module.exports = composePlugins(withNx(), withReact(), (config) => { } console.log('config', process.env.NX_DESKTOP_FROM_DIST) + + // Dev-server settings: allow ngrok hostnames (avoids "Invalid Host header") + // This only affects `yarn serve` (webpack dev server), not production builds. + config.devServer = { + ...(config.devServer || {}), + host: '0.0.0.0', + allowedHosts: 'all', + headers: { + ...(config.devServer && config.devServer.headers ? config.devServer.headers : {}), + 'ngrok-skip-browser-warning': '1', + }, + historyApiFallback: true, + } + return config; }); diff --git a/libs/endpoints-helper/src/index.ts b/libs/endpoints-helper/src/index.ts index f1a4f84f56e..278bfbe12fb 100644 --- a/libs/endpoints-helper/src/index.ts +++ b/libs/endpoints-helper/src/index.ts @@ -14,6 +14,8 @@ type EndpointUrls = { vyper2: string; solidityScanWebSocket: string; gitHubLoginProxy: string; + sso: string; + ssoPlugin: string; }; const defaultUrls: EndpointUrls = { @@ -32,6 +34,8 @@ const defaultUrls: EndpointUrls = { completion: 'https://completion.api.remix.live', solidityScanWebSocket: 'wss://solidityscan.api.remix.live', gitHubLoginProxy: 'https://github-login-proxy.api.remix.live', + sso: 'https://sso.api.remix.live', + ssoPlugin: 'https://sso-plugin.api.remix.live', }; const endpointPathMap: Record = { @@ -50,6 +54,8 @@ const endpointPathMap: Record = { vyper2: 'vyper2', solidityScanWebSocket: '', gitHubLoginProxy: 'github-login-proxy', + sso: 'sso', + ssoPlugin: 'sso-plugin', }; const prefix = process.env.NX_ENDPOINTS_URL; diff --git a/libs/remix-ai-core/src/inferencers/remote/remoteInference.ts b/libs/remix-ai-core/src/inferencers/remote/remoteInference.ts index 87cd8e00620..1373c073ed3 100644 --- a/libs/remix-ai-core/src/inferencers/remote/remoteInference.ts +++ b/libs/remix-ai-core/src/inferencers/remote/remoteInference.ts @@ -63,7 +63,11 @@ export class RemoteInferencer implements ICompletions, IGeneration { } try { - const options = AIRequestType.COMPLETION ? { headers: { 'Content-Type': 'application/json', }, timeout: 3000 } : { headers: { 'Content-Type': 'application/json', } } + const token = typeof window !== 'undefined' ? window.localStorage?.getItem('remix_pro_token') : undefined + const authHeader = token ? { 'Authorization': `Bearer ${token}` } : {} + const options = AIRequestType.COMPLETION + ? { headers: { 'Content-Type': 'application/json', ...authHeader }, timeout: 3000 } + : { headers: { 'Content-Type': 'application/json', ...authHeader } } const result = await axios.post(requestURL, payload, options) switch (rType) { @@ -104,10 +108,13 @@ export class RemoteInferencer implements ICompletions, IGeneration { try { this.event.emit('onInference') const requestURL = rType === AIRequestType.COMPLETION ? this.completion_url : this.api_url + const token = typeof window !== 'undefined' ? window.localStorage?.getItem('remix_pro_token') : undefined + const authHeader = token ? { 'Authorization': `Bearer ${token}` } : {} const response = await fetch(requestURL, { method: 'POST', headers: { 'Content-Type': 'application/json', + ...authHeader, }, body: JSON.stringify(payload), }); diff --git a/libs/remix-api/src/index.ts b/libs/remix-api/src/index.ts index f9615df6d24..90894f99211 100644 --- a/libs/remix-api/src/index.ts +++ b/libs/remix-api/src/index.ts @@ -3,4 +3,5 @@ export * from './lib/types/git' export * from './lib/types/desktopConnection' export * from './lib/plugins/matomo-api' export * from './lib/plugins/matomo-events' -export * from './lib/plugins/matomo-tracker' \ No newline at end of file +export * from './lib/plugins/matomo-tracker' +export * from './lib/plugins/sso-api' \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/sso-api.ts b/libs/remix-api/src/lib/plugins/sso-api.ts new file mode 100644 index 00000000000..cf4622a6eb9 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/sso-api.ts @@ -0,0 +1,45 @@ +import { StatusEvents } from "@remixproject/plugin-utils" + +export interface AuthUser { + sub: string + email?: string + name?: string + picture?: string + address?: string + chainId?: number + provider: 'google' | 'apple' | 'discord' | 'coinbase' | 'siwe' +} + +export interface AuthState { + isAuthenticated: boolean + user: AuthUser | null + token: string | null +} + +export type AuthProvider = 'google' | 'apple' | 'discord' | 'coinbase' | 'siwe' + +export interface ISSOApi { + events: { + authStateChanged: (authState: AuthState) => void + loginSuccess: (data: { user: AuthUser }) => void + loginError: (data: { provider: AuthProvider; error: string }) => void + logout: () => void + tokenRefreshed: (data: { token: string }) => void + openWindow: (data: { url: string; id: string }) => void + } & StatusEvents + methods: { + login(provider: AuthProvider): Promise + logout(): Promise + getToken(): Promise + getUser(): Promise + isAuthenticated(): Promise + refreshToken(): Promise + handlePopupResult(result: { + id: string + success: boolean + user?: AuthUser + accessToken?: string + error?: string + }): Promise + } +} diff --git a/libs/remix-api/src/lib/remix-api.ts b/libs/remix-api/src/lib/remix-api.ts index df86e9840a2..0beee073753 100644 --- a/libs/remix-api/src/lib/remix-api.ts +++ b/libs/remix-api/src/lib/remix-api.ts @@ -21,6 +21,7 @@ import { IPopupPanelAPI } from "./plugins/popuppanel-api" import { IDesktopClient } from "./plugins/desktop-client" import { IGitHubAuthHandlerApi } from "./plugins/githubAuthHandler-api" import { ITopbarApi } from "./plugins/topbar-api" +import { ISSOApi } from "./plugins/sso-api" export interface ICustomRemixApi extends IRemixApi { popupPanel: IPopupPanelAPI @@ -45,6 +46,7 @@ export interface ICustomRemixApi extends IRemixApi { remixAID: IRemixAID desktopClient: IDesktopClient githubAuthHandler: IGitHubAuthHandlerApi + sso: ISSOApi } export declare type CustomRemixApi = Readonly diff --git a/libs/remix-ui/app/src/index.ts b/libs/remix-ui/app/src/index.ts index 4602431686f..725a68f306b 100644 --- a/libs/remix-ui/app/src/index.ts +++ b/libs/remix-ui/app/src/index.ts @@ -5,3 +5,5 @@ export { AppModal } from './lib/remix-app/interface/index' export { AlertModal, AppState } from './lib/remix-app/interface/index' export { ModalTypes, AppModalCancelTypes } from './lib/remix-app/types/index' export { AppAction, appActionTypes } from './lib/remix-app/actions/app' +export { AuthProvider, useAuth, Credits, AuthState } from './lib/remix-app/context/auth-context' +export { AuthUser, AuthProvider as AuthProviderType } from '@remix-api' diff --git a/libs/remix-ui/app/src/lib/remix-app/context/auth-context.tsx b/libs/remix-ui/app/src/lib/remix-app/context/auth-context.tsx new file mode 100644 index 00000000000..4eebe3680ea --- /dev/null +++ b/libs/remix-ui/app/src/lib/remix-app/context/auth-context.tsx @@ -0,0 +1,227 @@ +import React, { createContext, useContext, useReducer, useEffect, useState, ReactNode } from 'react' +import { AuthUser, AuthProvider as AuthProviderType } from '@remix-api' +import { Profile } from '@remixproject/plugin-utils' + +export interface Credits { + balance: number + free_credits: number + paid_credits: number +} + +export interface AuthState { + isAuthenticated: boolean + user: AuthUser | null + token: string | null + credits: Credits | null + loading: boolean + error: string | null +} + +type AuthAction = + | { type: 'AUTH_START' } + | { type: 'AUTH_SUCCESS'; payload: { user: AuthUser; token: string } } + | { type: 'AUTH_FAILURE'; payload: string } + | { type: 'UPDATE_CREDITS'; payload: Credits } + | { type: 'LOGOUT' } + | { type: 'CLEAR_ERROR' } + +interface AuthContextValue extends AuthState { + login: (provider: AuthProviderType) => Promise + logout: () => Promise + refreshCredits: () => Promise + dispatch: React.Dispatch +} + +const AuthContext = createContext(undefined) + +const authReducer = (state: AuthState, action: AuthAction): AuthState => { + switch (action.type) { + case 'AUTH_START': + return { ...state, loading: true, error: null } + case 'AUTH_SUCCESS': + return { + ...state, + loading: false, + isAuthenticated: true, + user: action.payload.user, + token: action.payload.token, + error: null + } + case 'AUTH_FAILURE': + return { + ...state, + loading: false, + error: action.payload + } + case 'UPDATE_CREDITS': + return { + ...state, + credits: action.payload + } + case 'LOGOUT': + return { + isAuthenticated: false, + user: null, + token: null, + credits: null, + loading: false, + error: null + } + case 'CLEAR_ERROR': + return { ...state, error: null } + default: + return state + } +} + +const initialState: AuthState = { + isAuthenticated: false, + user: null, + token: null, + credits: null, + loading: false, + error: null +} + +interface AuthProviderProps { + children: ReactNode + plugin: any +} + +export const AuthProvider: React.FC = ({ children, plugin }) => { + const [state, dispatch] = useReducer(authReducer, initialState) + const [isReady, setIsReady] = useState(false) + + // Wait for plugin to be ready + useEffect(() => { + if (!plugin) return + + // Small delay to ensure plugin is activated + const timer = setTimeout(() => { + setIsReady(true) + }, 5000) + + return () => clearTimeout(timer) + }, [plugin]) + + // Initialize auth state on mount + useEffect(() => { + if (!isReady || !plugin) return + + const initAuth = async () => { + try { + const isAuth = await plugin.isAuthenticated() + if (isAuth) { + const user = await plugin.getUser() + const token = await plugin.getToken() + if (user && token) { + dispatch({ type: 'AUTH_SUCCESS', payload: { user, token } }) + } + + // Fetch credits + const credits = await plugin.getCredits() + if (credits) { + dispatch({ type: 'UPDATE_CREDITS', payload: credits }) + } + } + } catch (error) { + console.error('[AuthContext] Failed to restore session:', error) + } + } + + initAuth() + + // Listen to auth plugin events + const handleAuthStateChanged = (authState: any) => { + console.log('[AuthContext] Auth state changed:', authState) + if (authState.isAuthenticated && authState.user) { + dispatch({ + type: 'AUTH_SUCCESS', + payload: { user: authState.user, token: authState.token || null } + }) + } else { + dispatch({ type: 'LOGOUT' }) + } + } + + const handleCreditsUpdated = (credits: Credits) => { + console.log('[AuthContext] Credits updated:', credits) + dispatch({ type: 'UPDATE_CREDITS', payload: credits }) + } + + console.log('[AuthContext] Setting up event listeners, plugin.on exists:', typeof plugin.on) + plugin.call('manager', 'isActive', 'auth').then((result) => { + if (result) { + plugin.on('auth', 'authStateChanged', handleAuthStateChanged) + plugin.on('auth', 'creditsUpdated', handleCreditsUpdated) + } else { + plugin.on('manager', 'activate', (profile: Profile) => { + switch (profile.name) { + case 'auth': + plugin.on('auth', 'authStateChanged', handleAuthStateChanged) + plugin.on('auth', 'creditsUpdated', handleCreditsUpdated) + break + } + }) + } + }) + console.log('[AuthContext] Event listeners registered') + + return () => { + plugin.off('auth', 'authStateChanged', handleAuthStateChanged) + plugin.off('auth', 'creditsUpdated', handleCreditsUpdated) + } + }, [plugin, isReady]) + + const login = async (provider: AuthProviderType) => { + if (!isReady || !plugin) { + dispatch({ type: 'AUTH_FAILURE', payload: 'Authentication system not ready' }) + throw new Error('Authentication system not ready') + } + + try { + dispatch({ type: 'AUTH_START' }) + await plugin.login(provider) + } catch (error: any) { + dispatch({ type: 'AUTH_FAILURE', payload: error.message || 'Login failed' }) + throw error + } + } + + const logout = async () => { + if (!isReady || !plugin) return + + try { + await plugin.logout() + dispatch({ type: 'LOGOUT' }) + } catch (error) { + console.error('[AuthContext] Logout failed:', error) + } + } + + const refreshCredits = async () => { + if (!plugin) return + const credits = await plugin.refreshCredits() + if (credits) { + dispatch({ type: 'UPDATE_CREDITS', payload: credits }) + } + } + + const value: AuthContextValue = { + ...state, + login, + logout, + refreshCredits, + dispatch + } + + return {children} +} + +export const useAuth = (): AuthContextValue => { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} diff --git a/libs/remix-ui/app/src/lib/remix-app/remix-app.tsx b/libs/remix-ui/app/src/lib/remix-app/remix-app.tsx index 6261354602c..8f9638973e6 100644 --- a/libs/remix-ui/app/src/lib/remix-app/remix-app.tsx +++ b/libs/remix-ui/app/src/lib/remix-app/remix-app.tsx @@ -7,6 +7,7 @@ import ManagePreferencesDialog from './components/modals/managePreferences' import OriginWarning from './components/modals/origin-warning' import DragBar from './components/dragbar/dragbar' import { AppProvider } from './context/provider' +import { AuthProvider } from './context/auth-context' import AppDialogs from './components/modals/dialogs' import DialogViewPlugin from './components/modals/dialogViewPlugin' import { appProviderContextType, onLineContext, platformContext } from './context/context' @@ -44,7 +45,7 @@ const RemixApp = (props: IRemixAppUi) => { const [appState, appStateDispatch] = useReducer(appReducer, { ...appInitialState, showPopupPanel: !window.localStorage.getItem('did_show_popup_panel') && !isElectron(), - connectedToDesktop: props.app.desktopClientMode ? desktopConnectionType .disconnected : desktopConnectionType .disabled, + connectedToDesktop: props.app.desktopClientMode ? desktopConnectionType.disconnected : desktopConnectionType.disabled, genericModalState: { id: '', title:
Default Title
, @@ -62,7 +63,7 @@ const RemixApp = (props: IRemixAppUi) => { const [isAiWorkspaceBeingGenerated, setIsAiWorkspaceBeingGenerated] = useState(false) useEffect(() => { - if (props.app.params && props.app.params.activate && props.app.params.activate.split(',').includes('desktopClient')){ + if (props.app.params && props.app.params.activate && props.app.params.activate.split(',').includes('desktopClient')) { setHideSidePanel(true) } async function activateApp() { @@ -82,10 +83,10 @@ const RemixApp = (props: IRemixAppUi) => { if (!appState.showPopupPanel) { window.localStorage.setItem('did_show_popup_panel', 'true') } - },[appState.showPopupPanel]) + }, [appState.showPopupPanel]) function setListeners() { - if (!props.app.desktopClientMode){ + if (!props.app.desktopClientMode) { props.app.sidePanel.events.on('toggle', () => { setHideSidePanel((prev) => { return !prev @@ -177,67 +178,69 @@ const RemixApp = (props: IRemixAppUi) => { - - setShowManagePreferencesDialog(true)}> - {showManagePreferencesDialog && } -
- {!props.app.desktopClientMode && ( -
- {props.app.topBar.render()} -
- )} -
-
- {props.app.menuicons.render()} -
-
- {props.app.sidePanel.render()} -
- -
- -
-
- {props.app.pinnedPanel.render()} -
- { - !hidePinnedPanel && + + + setShowManagePreferencesDialog(true)}> + {showManagePreferencesDialog && } +
+ {!props.app.desktopClientMode && ( +
+ {props.app.topBar.render()} +
+ )} +
+
+ {props.app.menuicons.render()} +
+
+ {props.app.sidePanel.render()} +
- } -
{props.app.hiddenPanel.render()}
-
- {/*
{props.app.popupPanel.render()}
*/} -
- {props.app.statusBar.render()} +
+ +
+
+ {props.app.pinnedPanel.render()} +
+ { + !hidePinnedPanel && + + } +
{props.app.hiddenPanel.render()}
+
+ {/*
{props.app.popupPanel.render()}
*/} +
+ {props.app.statusBar.render()} +
-
- - - {appState.genericModalState.showModal && props.app.templateExplorerModal.render() - } + + + {appState.genericModalState.showModal && props.app.templateExplorerModal.render() + } + diff --git a/libs/remix-ui/git/src/components/github/devicecode.tsx b/libs/remix-ui/git/src/components/github/devicecode.tsx index 7cdfaf51058..0ada393ba2c 100644 --- a/libs/remix-ui/git/src/components/github/devicecode.tsx +++ b/libs/remix-ui/git/src/components/github/devicecode.tsx @@ -108,7 +108,7 @@ export const ConnectToGitHub = () => { {(context.gitHubUser && context.gitHubUser.isConnected) ? null : <> {popupError && !gitHubResponse && !authorized && (
diff --git a/libs/remix-ui/git/src/components/panels/githubcredentials.tsx b/libs/remix-ui/git/src/components/panels/githubcredentials.tsx index a27f1e2cab5..73cf98fd7a6 100644 --- a/libs/remix-ui/git/src/components/panels/githubcredentials.tsx +++ b/libs/remix-ui/git/src/components/panels/githubcredentials.tsx @@ -108,7 +108,7 @@ export const GitHubCredentials = () => {
{scopeWarning ? -
Your GitHub token may or may not have the correct permissions. Remix can't verify the permissions when using your own token. Please use the login with GitHub feature.
: null} +
Your GitHub token may or may not have the correct permissions. Remix can't verify the permissions when using your own token. Please use the connect with GitHub feature.
: null}
); diff --git a/libs/remix-ui/git/src/components/panels/tokenWarning.tsx b/libs/remix-ui/git/src/components/panels/tokenWarning.tsx index 077d3908fe4..0cd6d4b5fbf 100644 --- a/libs/remix-ui/git/src/components/panels/tokenWarning.tsx +++ b/libs/remix-ui/git/src/components/panels/tokenWarning.tsx @@ -6,7 +6,7 @@ export const TokenWarning = () => { return (<> {(context.gitHubUser && context.gitHubUser.login) ? null : - Generate and add a Git token or login with GitHub. Tokens are added in { + Generate and add a Git token or connect with GitHub. Tokens are added in { }}>settings. } diff --git a/libs/remix-ui/git/src/lib/gitactions.ts b/libs/remix-ui/git/src/lib/gitactions.ts index 36d86d0b6d4..6a86cbb2a6c 100644 --- a/libs/remix-ui/git/src/lib/gitactions.ts +++ b/libs/remix-ui/git/src/lib/gitactions.ts @@ -658,6 +658,7 @@ export const loadGitHubUserFromToken = async () => { appDispatcher({ type: appActionTypes.setGitHubUser, payload: data.user }) dispatch(setScopes(data.scopes)) dispatch(setUserEmails(data.emails)) + sendToGitLog({ type: 'success', message: `Github user loaded...` diff --git a/libs/remix-ui/login/LOGIN_USAGE_EXAMPLES.md b/libs/remix-ui/login/LOGIN_USAGE_EXAMPLES.md new file mode 100644 index 00000000000..7bc0edcd88e --- /dev/null +++ b/libs/remix-ui/login/LOGIN_USAGE_EXAMPLES.md @@ -0,0 +1,400 @@ +# Universal Login System - Usage Examples + +## Overview + +The universal login system provides a complete authentication solution that can be used anywhere in the Remix IDE React tree. It includes: + +- **AuthProvider**: Global auth state management via React Context +- **LoginButton**: Universal component with 3 variants (button, badge, compact) +- **LoginModal**: Modal overlay with all authentication providers +- **useAuth**: Hook to access auth state and actions anywhere + +## Components + +### 1. AuthProvider + +Already integrated into the main `remix-app.tsx`. Wraps the entire app and provides auth context. + +```tsx + + {/* Your app */} + +``` + +### 2. LoginButton Component + +The main component you'll use throughout the app. It has 3 variants: + +#### Variant: "button" (default) +Full button with credits display and dropdown menu. + +```tsx +import { LoginButton } from '@remix-ui/app' + + +``` + +**Displays:** +- When logged out: `🔐 Sign In` button +- When logged in: Credits badge + `👤 Username` dropdown with: + - User info (name, email, provider) + - Credit details (total, free, paid) + - Sign Out button + +#### Variant: "badge" +Compact badge display with dropdown. + +```tsx + +``` + +**Displays:** +- When logged out: `🔐 Login` button +- When logged in: `✓ Username [123 credits]` dropdown + +#### Variant: "compact" +Icon-only display for tight spaces (like toolbar). + +```tsx + +``` + +**Displays:** +- When logged out: `🔐 Login` button +- When logged in: `👤` icon with dropdown + +### 3. useAuth Hook + +Access auth state and actions from any component. + +```tsx +import { useAuth } from '@remix-ui/app' + +function MyComponent() { + const { + isAuthenticated, + user, + credits, + loading, + error, + login, + logout, + refreshCredits + } = useAuth() + + if (loading) return
Loading...
+ if (error) return
Error: {error}
+ + if (!isAuthenticated) { + return ( + + ) + } + + return ( +
+

Welcome, {user.name || user.email}!

+

Credits: {credits?.balance}

+ + +
+ ) +} +``` + +### 4. LoginModal + +The modal is automatically shown when LoginButton is clicked, but you can also use it directly: + +```tsx +import { LoginModal } from '@remix-ui/app' + +function MyComponent() { + const [showModal, setShowModal] = useState(false) + + return ( + <> + + {showModal && setShowModal(false)} />} + + ) +} +``` + +## Real-World Usage Examples + +### Example 1: Add Login to Menubar + +```tsx +// In your menubar component +import { LoginButton } from '@remix-ui/app' + +export const Menubar = () => { + return ( +
+
+ {/* Other menu items */} +
+ +
+ ) +} +``` + +### Example 2: Protected Feature with Credits Check + +```tsx +import { useAuth } from '@remix-ui/app' + +export const AIAssistant = () => { + const { isAuthenticated, credits, login } = useAuth() + + if (!isAuthenticated) { + return ( +
+
AI Assistant
+

Please sign in to use AI features

+ +
+ ) + } + + if (!credits || credits.balance < 1) { + return ( +
+ Insufficient credits. You have {credits?.balance || 0} credits. + Purchase more credits +
+ ) + } + + return ( +
+

Credits remaining: {credits.balance}

+ {/* AI Assistant UI */} +
+ ) +} +``` + +### Example 3: Sidebar with User Profile + +```tsx +import { useAuth, LoginButton } from '@remix-ui/app' + +export const Sidebar = () => { + const { isAuthenticated, user } = useAuth() + + return ( +
+
+ {isAuthenticated ? ( +
+ Profile +
{user.name || user.email}
+ +
+ ) : ( + + )} +
+ {/* Rest of sidebar */} +
+ ) +} +``` + +### Example 4: Conditional Rendering Based on Provider + +```tsx +import { useAuth } from '@remix-ui/app' + +export const WalletFeatures = () => { + const { user } = useAuth() + + // Only show wallet features for SIWE users + if (user?.provider !== 'siwe') { + return null + } + + return ( +
+
Wallet Features
+

Address: {user.address}

+

Chain ID: {user.chainId}

+ {/* Blockchain-specific features */} +
+ ) +} +``` + +### Example 5: Manual Login with Specific Provider + +```tsx +import { useAuth } from '@remix-ui/app' + +export const QuickLogin = () => { + const { login, loading } = useAuth() + + return ( +
+ + +
+ ) +} +``` + +## API Reference + +### AuthState + +```typescript +interface AuthState { + isAuthenticated: boolean + user: AuthUser | null + token: string | null + credits: Credits | null + loading: boolean + error: string | null +} +``` + +### AuthUser + +```typescript +interface AuthUser { + sub: string // Unique user ID + email?: string + name?: string + picture?: string + address?: string // For SIWE users + chainId?: number // For SIWE users + provider: 'google' | 'apple' | 'discord' | 'coinbase' | 'siwe' +} +``` + +### Credits + +```typescript +interface Credits { + balance: number // Total credits + free_credits: number // Free credits remaining + paid_credits: number // Paid credits remaining +} +``` + +### useAuth Hook + +```typescript +const { + // State + isAuthenticated: boolean + user: AuthUser | null + token: string | null + credits: Credits | null + loading: boolean + error: string | null + + // Actions + login: (provider: 'google' | 'apple' | 'discord' | 'coinbase' | 'siwe') => Promise + logout: () => Promise + refreshCredits: () => Promise + dispatch: React.Dispatch +} = useAuth() +``` + +### LoginButton Props + +```typescript +interface LoginButtonProps { + className?: string // Additional CSS classes + showCredits?: boolean // Show/hide credits (default: true) + variant?: 'button' | 'badge' | 'compact' // Display variant (default: 'button') +} +``` + +## Authentication Providers + +The system supports 5 authentication providers: + +1. **Google** 🔵 - OAuth via Google accounts +2. **Discord** 💬 - OAuth via Discord accounts +3. **SIWE** 🦊 - Sign-In With Ethereum (MetaMask, Coinbase Wallet, etc.) +4. **Apple** 🍎 - OAuth via Apple ID +5. **Coinbase** 🔷 - OAuth via Coinbase (currently disabled) + +## Events + +The system automatically listens to SSO plugin events: + +- `authStateChanged` - Fired when auth state changes +- `loginSuccess` - Fired on successful login +- `loginError` - Fired on login failure +- `logout` - Fired when user logs out +- `tokenRefreshed` - Fired when access token is refreshed + +These are handled automatically by the AuthProvider. You don't need to listen to them manually unless you have specific needs. + +## Best Practices + +1. **Use LoginButton for most cases** - It handles all the UI logic +2. **Use useAuth for custom logic** - When you need programmatic access to auth state +3. **Check credits before expensive operations** - Especially for AI/API features +4. **Refresh credits after operations** - Call `refreshCredits()` after API calls that consume credits +5. **Handle loading states** - Always check `loading` from useAuth +6. **Handle errors gracefully** - Display `error` to users when present +7. **Use appropriate variant** - `compact` for toolbars, `button` for prominent areas, `badge` for sidebars + +## Migration from SSO Demo Plugin + +If you're currently using the SSO Demo plugin directly: + +**Before:** +```tsx +await plugin.call('sso', 'login', 'google') +const user = await plugin.call('sso', 'getUser') +const isAuth = await plugin.call('sso', 'isAuthenticated') +``` + +**After:** +```tsx +import { useAuth } from '@remix-ui/app' + +const { login, user, isAuthenticated } = useAuth() +await login('google') +// user and isAuthenticated are automatically updated +``` + +The new system is fully compatible with the SSO plugin and provides a much cleaner API! diff --git a/libs/remix-ui/login/src/index.ts b/libs/remix-ui/login/src/index.ts new file mode 100644 index 00000000000..34774d092cc --- /dev/null +++ b/libs/remix-ui/login/src/index.ts @@ -0,0 +1,5 @@ +export { LoginButton } from './lib/login-button' +export { UserBadge } from './lib/user-badge' +export { UserMenuCompact } from './lib/user-menu-compact' +export { UserMenuFull } from './lib/user-menu-full' +export { LoginModal } from './lib/modals/login-modal' diff --git a/libs/remix-ui/login/src/lib/login-button.tsx b/libs/remix-ui/login/src/lib/login-button.tsx new file mode 100644 index 00000000000..87fb04816c0 --- /dev/null +++ b/libs/remix-ui/login/src/lib/login-button.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react' +import { useAuth } from '../../../app/src/lib/remix-app/context/auth-context' +import { LoginModal } from './modals/login-modal' +import { UserBadge } from './user-badge' +import { UserMenuCompact } from './user-menu-compact' +import { UserMenuFull } from './user-menu-full' + +interface LoginButtonProps { + className?: string + showCredits?: boolean + variant?: 'button' | 'badge' | 'compact' +} + +export const LoginButton: React.FC = ({ + className = '', + showCredits = true, + variant = 'button' +}) => { + const { isAuthenticated, user, credits, logout } = useAuth() + const [showModal, setShowModal] = useState(false) + + const handleLogout = async () => { + await logout() + } + + const formatAddress = (address: string) => { + if (!address) return '' + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}` + } + + const getProviderDisplayName = (provider: string) => { + const providerNames: Record = { + 'google': 'Google', + 'apple': 'Apple', + 'discord': 'Discord', + 'coinbase': 'Coinbase Wallet', + 'siwe': 'Ethereum' + } + return providerNames[provider] || provider + } + + const getUserDisplayName = () => { + if (!user) return 'Unknown' + if (user.name) return user.name + if (user.email) return user.email + if (user.address) return formatAddress(user.address) + return user.sub + } + + if (!isAuthenticated) { + return ( + <> + + {showModal && setShowModal(false)} />} + + ) + } + + if (variant === 'badge') { + return ( + + ) + } + + if (variant === 'compact') { + return ( + + ) + } + + return ( + + ) +} diff --git a/libs/remix-ui/login/src/lib/modals/login-modal.tsx b/libs/remix-ui/login/src/lib/modals/login-modal.tsx new file mode 100644 index 00000000000..31ef6eabfc7 --- /dev/null +++ b/libs/remix-ui/login/src/lib/modals/login-modal.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useState } from 'react' +import { AuthProvider } from '@remix-api' +import { useAuth } from '../../../../app/src/lib/remix-app/context/auth-context' + +interface LoginModalProps { + onClose: () => void +} + +interface ProviderConfig { + id: AuthProvider + label: string + icon: JSX.Element + description: string + enabled: boolean +} + +export const LoginModal: React.FC = ({ onClose }) => { + const { login, loading, error } = useAuth() + const [providers, setProviders] = useState([]) + const [loadingProviders, setLoadingProviders] = useState(true) + + useEffect(() => { + const fetchSupportedProviders = async () => { + try { + // Detect environment + const baseUrl = window.location.hostname.includes('localhost') + ? 'http://localhost:3000' + : window.location.hostname.includes('ngrok') + ? 'https://endpoints-remix-dev.ngrok.dev' + : 'https://sso.api.remix.live' + + const response = await fetch(`${baseUrl}/sso/providers`, { + method: 'GET', + headers: { 'Accept': 'application/json' } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch providers: ${response.status}`) + } + + const data = await response.json() + console.log('[LoginModal] Supported providers from backend:', data) + + // Map backend response to UI config + const allProviders: ProviderConfig[] = [ + { + id: 'google', + label: 'Google', + icon: , + description: 'Sign in with your Google account', + enabled: data.providers?.includes('google') ?? false + }, + { + id: 'discord', + label: 'Discord', + icon: , + description: 'Sign in with your Discord account', + enabled: data.providers?.includes('discord') ?? false + }, + { + id: 'siwe', + label: 'Ethereum Wallet', + icon: , + description: 'Sign in with MetaMask, Coinbase Wallet, or any Ethereum wallet', + enabled: data.providers?.includes('siwe') ?? false + }, + { + id: 'apple', + label: 'Apple', + icon: , + description: 'Sign in with your Apple ID', + enabled: data.providers?.includes('apple') ?? false + }, + { + id: 'coinbase', + label: 'Coinbase', + icon: , + description: 'Sign in with your Coinbase account', + enabled: data.providers?.includes('coinbase') ?? false + } + ] + + // Only show enabled providers + setProviders(allProviders.filter(p => p.enabled)) + setLoadingProviders(false) + } catch (err) { + console.error('[LoginModal] Failed to fetch providers:', err) + // Fallback to default providers if API fails + setProviders([ + { + id: 'google', + label: 'Google', + icon: , + description: 'Sign in with your Google account', + enabled: true + }, + { + id: 'discord', + label: 'Discord', + icon: , + description: 'Sign in with your Discord account', + enabled: true + }, + { + id: 'siwe', + label: 'Ethereum Wallet', + icon: , + description: 'Sign in with MetaMask, Coinbase Wallet, or any Ethereum wallet', + enabled: true + } + ]) + setLoadingProviders(false) + } + } + + fetchSupportedProviders() + }, []) + + const handleLogin = async (provider: AuthProvider) => { + try { + await login(provider) + // Modal will auto-close via auth state change + } catch (err) { + // Error is handled by context + console.error('[LoginModal] Login failed:', err) + } + } + + return ( +
+
e.stopPropagation()} + style={{ maxWidth: '500px', width: '90%' }} + > +
+
+
Log in to Remix IDE
+
+ +
+
+
+

+ Choose your preferred authentication method to access special Remix features and manage your credits. +

+ + {error && ( +
+ Error: {error} +
+ )} + + {loadingProviders ? ( +
+
+ Loading providers... +
+

Loading authentication methods...

+
+ ) : providers.length === 0 ? ( +
+ No authentication providers are currently available. Please try again later. +
+ ) : ( +
+ {providers.map((provider) => ( + + ))} +
+ )} + +
+ + By signing in, you agree to our{' '} + + Terms and Conditions + + +
+
+
+
+
+ ) +} diff --git a/libs/remix-ui/login/src/lib/user-badge.tsx b/libs/remix-ui/login/src/lib/user-badge.tsx new file mode 100644 index 00000000000..5d249e21df7 --- /dev/null +++ b/libs/remix-ui/login/src/lib/user-badge.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react' +import { AuthUser } from '@remix-api' +import type { Credits } from '../../../app/src/lib/remix-app/context/auth-context' + +interface UserBadgeProps { + user: AuthUser + credits: Credits | null + showCredits: boolean + className?: string + onLogout: () => void + formatAddress: (address: string) => string + getProviderDisplayName: (provider: string) => string + getUserDisplayName: () => string +} + +export const UserBadge: React.FC = ({ + user, + credits, + showCredits, + className, + onLogout, + getProviderDisplayName, + getUserDisplayName +}) => { + const [showDropdown, setShowDropdown] = useState(false) + + return ( +
+
+ + {showDropdown && ( +
+
+ {user.picture && ( +
+ Avatar +
+ )} +
{getUserDisplayName()}
+
{getProviderDisplayName(user.provider)}
+
+ {credits && ( + <> +
+
+
+ Total Credits: + {credits.balance} +
+
+ Free: + {credits.free_credits} +
+
+ Paid: + {credits.paid_credits} +
+
+ + )} +
+ +
+ )} +
+ {/* Backdrop to close dropdown */} + {showDropdown && ( +
setShowDropdown(false)} + /> + )} +
+ ) +} diff --git a/libs/remix-ui/login/src/lib/user-menu-compact.tsx b/libs/remix-ui/login/src/lib/user-menu-compact.tsx new file mode 100644 index 00000000000..8d52302ed93 --- /dev/null +++ b/libs/remix-ui/login/src/lib/user-menu-compact.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react' +import { AuthUser } from '@remix-api' +import type { Credits } from '../../../app/src/lib/remix-app/context/auth-context' + +interface UserMenuCompactProps { + user: AuthUser + credits: Credits | null + showCredits: boolean + className?: string + onLogout: () => void + getProviderDisplayName: (provider: string) => string + getUserDisplayName: () => string +} + +export const UserMenuCompact: React.FC = ({ + user, + credits, + showCredits, + className, + onLogout, + getProviderDisplayName, + getUserDisplayName +}) => { + const [showDropdown, setShowDropdown] = useState(false) + + return ( +
+ + {showDropdown && ( + <> +
+
+ {user.picture && ( +
+ Avatar +
+ )} +
{getUserDisplayName()}
+
{getProviderDisplayName(user.provider)}
+
+ {credits && showCredits && ( + <> +
+
+
+ Credits: + {credits.balance} +
+
+ + )} +
+ +
+
setShowDropdown(false)} + /> + + )} +
+ ) +} diff --git a/libs/remix-ui/login/src/lib/user-menu-full.tsx b/libs/remix-ui/login/src/lib/user-menu-full.tsx new file mode 100644 index 00000000000..0db20bf5f9b --- /dev/null +++ b/libs/remix-ui/login/src/lib/user-menu-full.tsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react' +import { AuthUser } from '@remix-api' +import type { Credits } from '../../../app/src/lib/remix-app/context/auth-context' + +interface UserMenuFullProps { + user: AuthUser + credits: Credits | null + showCredits: boolean + className?: string + onLogout: () => void + formatAddress: (address: string) => string + getProviderDisplayName: (provider: string) => string + getUserDisplayName: () => string +} + +export const UserMenuFull: React.FC = ({ + user, + credits, + showCredits, + className, + onLogout, + formatAddress, + getProviderDisplayName, + getUserDisplayName +}) => { + const [showDropdown, setShowDropdown] = useState(false) + + return ( +
+ {credits && showCredits && ( +
+ {credits.balance} credits +
+ )} +
+ + {showDropdown && ( + <> +
+
+ {user.picture && ( +
+ Avatar +
+ )} +
{getUserDisplayName()}
+
{getProviderDisplayName(user.provider)}
+ {user.email &&
{user.email}
} + {user.address &&
{formatAddress(user.address)}
} +
+ {credits && ( + <> +
+
+
+ Total Credits: + {credits.balance} +
+
+ Free: + {credits.free_credits} +
+
+ Paid: + {credits.paid_credits} +
+
+ + )} +
+ +
+
setShowDropdown(false)} + /> + + )} +
+
+ ) +} diff --git a/libs/remix-ui/top-bar/src/components/gitLogin.tsx b/libs/remix-ui/top-bar/src/components/gitLogin.tsx index 6f338102964..c817ebb0a9d 100644 --- a/libs/remix-ui/top-bar/src/components/gitLogin.tsx +++ b/libs/remix-ui/top-bar/src/components/gitLogin.tsx @@ -29,6 +29,14 @@ export const GitHubLogin: React.FC = ({ const gitHubUser = appContext?.appState?.gitHubUser const isConnected = gitHubUser?.isConnected + // Persist minimal GH identity for billing callbacks + if (isConnected && gitHubUser?.login && gitHubUser?.id) { + try { + window.localStorage.setItem('gh_login', gitHubUser.login) + window.localStorage.setItem('gh_id', String(gitHubUser.id)) + } catch {} + } + // Simple login handler that delegates to the prop function const handleLogin = useCallback(async () => { try { @@ -64,7 +72,7 @@ export const GitHubLogin: React.FC = ({ ) : (
- Login with GitHub + Connect with GitHub
)} @@ -104,6 +112,10 @@ export const GitHubLogin: React.FC = ({ data-id="github-dropdown-item-disconnect" onClick={async () => { await logOutOfGithub() + try { + window.localStorage.removeItem('gh_login') + window.localStorage.removeItem('gh_id') + } catch {} trackMatomoEvent({ category: 'topbar', action: 'GIT', name: 'logout', isClick: true }) }} className="text-danger" diff --git a/libs/remix-ui/top-bar/src/lib/remix-ui-topbar.tsx b/libs/remix-ui/top-bar/src/lib/remix-ui-topbar.tsx index 9423ffe97a8..690b46e0a3a 100644 --- a/libs/remix-ui/top-bar/src/lib/remix-ui-topbar.tsx +++ b/libs/remix-ui/top-bar/src/lib/remix-ui-topbar.tsx @@ -17,6 +17,7 @@ import { GitHubLogin } from '../components/gitLogin' import { CustomTooltip } from 'libs/remix-ui/helper/src/lib/components/custom-tooltip' import { TrackingContext } from '@remix-ide/tracking' import { MatomoEvent, TopbarEvent, WorkspaceEvent } from '@remix-api' +import { LoginButton } from '@remix-ui/login' import { appActionTypes } from 'libs/remix-ui/app/src/lib/remix-app/actions/app' export function RemixUiTopbar() { @@ -159,6 +160,7 @@ export function RemixUiTopbar() { loadCurrentTheme() }, []); + const subItems = useMemo(() => { return [ { label: 'Rename', onClick: renameCurrentWorkspace, icon: 'far fa-edit' }, @@ -561,6 +563,11 @@ export function RemixUiTopbar() { publishToGist={publishToGist} loginWithGitHub={loginWithGitHub} /> +