diff --git a/extensions-web/src/index.ts b/extensions-web/src/index.ts index 9e7f3aab36..21b5ed462f 100644 --- a/extensions-web/src/index.ts +++ b/extensions-web/src/index.ts @@ -9,6 +9,15 @@ export { default as ConversationalExtensionWeb } from './conversational-web' export { default as JanProviderWeb } from './jan-provider-web' export { default as MCPExtensionWeb } from './mcp-web' +// Re-export auth functionality +export { + JanAuthService, + getSharedAuthService, + AUTH_STORAGE_KEYS, + AUTH_EVENTS, + AUTH_BROADCAST_CHANNEL, +} from './shared/auth' + // Re-export types export type { WebExtensionRegistry, @@ -17,12 +26,21 @@ export type { WebExtensionLoader, ConversationalWebModule, JanProviderWebModule, - MCPWebModule + MCPWebModule, } from './types' +// Re-export auth types +export type { + User, + AuthTokens, + AuthProvider, + AuthProviderRegistry, + ProviderType, +} from './shared/auth' + // Extension registry for dynamic loading export const WEB_EXTENSIONS: WebExtensionRegistry = { 'conversational-web': () => import('./conversational-web'), 'jan-provider-web': () => import('./jan-provider-web'), 'mcp-web': () => import('./mcp-web'), -} \ No newline at end of file +} diff --git a/extensions-web/src/shared/auth.ts b/extensions-web/src/shared/auth.ts deleted file mode 100644 index 8b44ed7144..0000000000 --- a/extensions-web/src/shared/auth.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Shared Authentication Service - * Handles guest login and token refresh for Jan API - */ - -// JAN_API_BASE is defined in vite.config.ts -declare const JAN_API_BASE: string - -export interface AuthTokens { - access_token: string - expires_in: number -} - -export interface AuthResponse { - access_token: string - expires_in: number -} - -const AUTH_STORAGE_KEY = 'jan_auth_tokens' -const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before actual expiry - -export class JanAuthService { - private tokens: AuthTokens | null = null - private tokenExpiryTime: number = 0 - - constructor() { - this.loadTokensFromStorage() - } - - private loadTokensFromStorage(): void { - try { - const storedTokens = localStorage.getItem(AUTH_STORAGE_KEY) - if (storedTokens) { - const parsed = JSON.parse(storedTokens) - this.tokens = parsed.tokens - this.tokenExpiryTime = parsed.expiryTime || 0 - } - } catch (error) { - console.warn('Failed to load tokens from storage:', error) - this.clearTokens() - } - } - - private saveTokensToStorage(): void { - if (this.tokens) { - try { - localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ - tokens: this.tokens, - expiryTime: this.tokenExpiryTime - })) - } catch (error) { - console.error('Failed to save tokens to storage:', error) - } - } - } - - private clearTokens(): void { - this.tokens = null - this.tokenExpiryTime = 0 - localStorage.removeItem(AUTH_STORAGE_KEY) - } - - private isTokenExpired(): boolean { - return Date.now() > (this.tokenExpiryTime - TOKEN_EXPIRY_BUFFER) - } - - private calculateExpiryTime(expiresIn: number): number { - return Date.now() + (expiresIn * 1000) - } - - private async guestLogin(): Promise { - try { - const response = await fetch(`${JAN_API_BASE}/auth/guest-login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // Include cookies for session management - }) - - if (!response.ok) { - throw new Error(`Guest login failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - // API response is wrapped in result object - const authResponse = data.result || data - - // Guest login returns only access_token and expires_in - const tokens: AuthTokens = { - access_token: authResponse.access_token, - expires_in: authResponse.expires_in - } - - this.tokens = tokens - this.tokenExpiryTime = this.calculateExpiryTime(tokens.expires_in) - this.saveTokensToStorage() - - return tokens - } catch (error) { - console.error('Guest login failed:', error) - throw error - } - } - - private async refreshToken(): Promise { - try { - const response = await fetch(`${JAN_API_BASE}/auth/refresh-token`, { - method: 'GET', - credentials: 'include', // Cookies will include the refresh token - }) - - if (!response.ok) { - if (response.status === 401) { - // Refresh token is invalid, clear tokens and do guest login - this.clearTokens() - return this.guestLogin() - } - throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - // API response is wrapped in result object - const authResponse = data.result || data - - // Refresh endpoint returns only access_token and expires_in - const tokens: AuthTokens = { - access_token: authResponse.access_token, - expires_in: authResponse.expires_in - } - - this.tokens = tokens - this.tokenExpiryTime = this.calculateExpiryTime(tokens.expires_in) - this.saveTokensToStorage() - - return tokens - } catch (error) { - console.error('Token refresh failed:', error) - // If refresh fails, fall back to guest login - this.clearTokens() - return this.guestLogin() - } - } - - async getValidAccessToken(): Promise { - // If no tokens exist, do guest login - if (!this.tokens) { - const tokens = await this.guestLogin() - return tokens.access_token - } - - // If token is expired or about to expire, refresh it - if (this.isTokenExpired()) { - const tokens = await this.refreshToken() - return tokens.access_token - } - - // Return existing valid token - return this.tokens.access_token - } - - async getAuthHeader(): Promise<{ Authorization: string }> { - const token = await this.getValidAccessToken() - return { - Authorization: `Bearer ${token}` - } - } - - async makeAuthenticatedRequest( - url: string, - options: RequestInit = {} - ): Promise { - try { - const authHeader = await this.getAuthHeader() - - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...authHeader, - ...options.headers, - }, - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`) - } - - return response.json() - } catch (error) { - console.error('API request failed:', error) - throw error - } - } - - logout(): void { - this.clearTokens() - } -} - -declare global { - interface Window { - janAuthService?: JanAuthService - } -} - -/** - * Gets or creates the shared JanAuthService instance on the window object - * This ensures all extensions use the same auth service instance - */ -export function getSharedAuthService(): JanAuthService { - if (!window.janAuthService) { - window.janAuthService = new JanAuthService() - } - return window.janAuthService -} \ No newline at end of file diff --git a/extensions-web/src/shared/auth/api.ts b/extensions-web/src/shared/auth/api.ts new file mode 100644 index 0000000000..1bfdae3c76 --- /dev/null +++ b/extensions-web/src/shared/auth/api.ts @@ -0,0 +1,68 @@ +/** + * Generic Authentication API Layer + * Generic API calls for authentication (not provider-specific) + */ + +import { AuthTokens } from './types' +import { AUTH_ENDPOINTS } from './const' + +declare const JAN_API_BASE: string + +/** + * Logout user on server + */ +export async function logoutUser(): Promise { + const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.LOGOUT}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + console.warn(`Logout failed with status: ${response.status}`) + } +} + +/** + * Guest login + */ +export async function guestLogin(): Promise { + const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.GUEST_LOGIN}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error( + `Guest login failed: ${response.status} ${response.statusText}` + ) + } + + return response.json() as Promise +} + +/** + * Refresh token (works for both guest and authenticated users) + */ +export async function refreshToken(): Promise { + const response = await fetch( + `${JAN_API_BASE}${AUTH_ENDPOINTS.REFRESH_TOKEN}`, + { + method: 'GET', + credentials: 'include', + } + ) + + if (!response.ok) { + throw new Error( + `Token refresh failed: ${response.status} ${response.statusText}` + ) + } + + return response.json() as Promise +} diff --git a/extensions-web/src/shared/auth/broadcast.ts b/extensions-web/src/shared/auth/broadcast.ts new file mode 100644 index 0000000000..8a2d316d48 --- /dev/null +++ b/extensions-web/src/shared/auth/broadcast.ts @@ -0,0 +1,92 @@ +/** + * Authentication Broadcast Channel Handler + * Manages cross-tab communication for auth state changes + */ + +import { AUTH_BROADCAST_CHANNEL, AUTH_EVENTS } from './const' +import type { AuthBroadcastMessage } from './types' + +export class AuthBroadcast { + private broadcastChannel: BroadcastChannel | null = null + + constructor() { + this.setupBroadcastChannel() + } + + /** + * Setup broadcast channel for cross-tab sync + */ + private setupBroadcastChannel(): void { + if (typeof BroadcastChannel !== 'undefined') { + try { + this.broadcastChannel = new BroadcastChannel(AUTH_BROADCAST_CHANNEL) + } catch (error) { + console.warn('BroadcastChannel not available:', error) + } + } + } + + /** + * Broadcast auth event to other tabs + */ + broadcastEvent(type: AuthBroadcastMessage): void { + if (this.broadcastChannel) { + try { + const message = { type } + this.broadcastChannel.postMessage(message) + } catch (error) { + console.warn('Failed to broadcast auth event:', error) + } + } + } + + /** + * Broadcast login event + */ + broadcastLogin(): void { + this.broadcastEvent(AUTH_EVENTS.LOGIN) + } + + /** + * Broadcast logout event + */ + broadcastLogout(): void { + this.broadcastEvent(AUTH_EVENTS.LOGOUT) + } + + /** + * Subscribe to auth events + */ + onAuthEvent( + listener: (event: MessageEvent<{ type: AuthBroadcastMessage }>) => void + ): () => void { + if (this.broadcastChannel) { + this.broadcastChannel.addEventListener('message', listener) + + // Return cleanup function + return () => { + this.broadcastChannel?.removeEventListener('message', listener) + } + } + + // Return no-op cleanup if no broadcast channel + return () => {} + } + + /** + * Get the broadcast channel for external listeners + */ + getBroadcastChannel(): BroadcastChannel | null { + return this.broadcastChannel + } + + /** + * Cleanup broadcast channel + */ + destroy(): void { + if (this.broadcastChannel) { + this.broadcastChannel.close() + this.broadcastChannel = null + } + } +} diff --git a/extensions-web/src/shared/auth/const.ts b/extensions-web/src/shared/auth/const.ts new file mode 100644 index 0000000000..efd5ad1960 --- /dev/null +++ b/extensions-web/src/shared/auth/const.ts @@ -0,0 +1,29 @@ +/** + * Authentication Constants and Configuration + * Generic constants used across all auth providers + */ + +// Storage keys +export const AUTH_STORAGE_KEYS = { + AUTH_PROVIDER: 'jan_auth_provider', +} as const + +// Generic API endpoints (provider-agnostic) +export const AUTH_ENDPOINTS = { + ME: '/auth/me', + LOGOUT: '/auth/logout', + GUEST_LOGIN: '/auth/guest-login', + REFRESH_TOKEN: '/auth/refresh-token', +} as const + +// Token expiry buffer +export const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before expiry + +// Broadcast channel for cross-tab communication +export const AUTH_BROADCAST_CHANNEL = 'jan_auth_channel' + +// Auth events +export const AUTH_EVENTS = { + LOGIN: 'auth:login', + LOGOUT: 'auth:logout', +} as const diff --git a/extensions-web/src/shared/auth/index.ts b/extensions-web/src/shared/auth/index.ts new file mode 100644 index 0000000000..1dbb9c47fe --- /dev/null +++ b/extensions-web/src/shared/auth/index.ts @@ -0,0 +1,10 @@ +// Main exports +export { JanAuthService, getSharedAuthService } from './service' +export { AuthProviderRegistry } from './registry' + +// Type exports +export type { AuthTokens, AuthType, User } from './types' +export type { AuthProvider, ProviderType } from './providers' + +// Constant exports +export { AUTH_STORAGE_KEYS, AUTH_EVENTS, AUTH_BROADCAST_CHANNEL } from './const' diff --git a/extensions-web/src/shared/auth/providers/api.ts b/extensions-web/src/shared/auth/providers/api.ts new file mode 100644 index 0000000000..f2830e911f --- /dev/null +++ b/extensions-web/src/shared/auth/providers/api.ts @@ -0,0 +1,49 @@ +/** + * Provider-specific API Layer + * API calls specific to authentication providers + */ + +import { AuthTokens, LoginUrlResponse } from './types' + +declare const JAN_API_BASE: string + +export async function getLoginUrl(endpoint: string): Promise { + const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error( + `Failed to get login URL: ${response.status} ${response.statusText}` + ) + } + + return response.json() as Promise +} + +export async function handleOAuthCallback( + endpoint: string, + code: string, + state?: string +): Promise { + const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ code, state }), + }) + + if (!response.ok) { + throw new Error( + `OAuth callback failed: ${response.status} ${response.statusText}` + ) + } + + return response.json() as Promise +} diff --git a/extensions-web/src/shared/auth/providers/base.ts b/extensions-web/src/shared/auth/providers/base.ts new file mode 100644 index 0000000000..016f21eed5 --- /dev/null +++ b/extensions-web/src/shared/auth/providers/base.ts @@ -0,0 +1,39 @@ +/** + * Base Auth Provider + * Abstract base class that all providers should extend + */ + +import { AuthProvider, AuthTokens } from './types' +import { getLoginUrl, handleOAuthCallback } from './api' + +export abstract class BaseAuthProvider implements AuthProvider { + abstract readonly id: string + abstract readonly name: string + abstract readonly icon: string + + abstract getLoginEndpoint(): string + abstract getCallbackEndpoint(): string + + async initiateLogin(): Promise { + try { + // Fetch login URL from API + const data = await getLoginUrl(this.getLoginEndpoint()) + + // Redirect to the OAuth URL provided by the API + window.location.href = data.url + } catch (error) { + console.error(`Failed to initiate ${this.id} login:`, error) + throw error + } + } + + async handleCallback(code: string, state?: string): Promise { + try { + // Handle OAuth callback and return token data + return await handleOAuthCallback(this.getCallbackEndpoint(), code, state) + } catch (error) { + console.error(`${this.name} callback handling failed:`, error) + throw error + } + } +} diff --git a/extensions-web/src/shared/auth/providers/google.ts b/extensions-web/src/shared/auth/providers/google.ts new file mode 100644 index 0000000000..87d3a9d17e --- /dev/null +++ b/extensions-web/src/shared/auth/providers/google.ts @@ -0,0 +1,20 @@ +/** + * Google Auth Provider + * Specific implementation for Google OAuth + */ + +import { BaseAuthProvider } from './base' + +export class GoogleAuthProvider extends BaseAuthProvider { + readonly id = 'google' + readonly name = 'Google' + readonly icon = 'IconBrandGoogleFilled' + + getLoginEndpoint(): string { + return '/auth/google/login' + } + + getCallbackEndpoint(): string { + return '/auth/google/callback' + } +} diff --git a/extensions-web/src/shared/auth/providers/index.ts b/extensions-web/src/shared/auth/providers/index.ts new file mode 100644 index 0000000000..a168c9a88a --- /dev/null +++ b/extensions-web/src/shared/auth/providers/index.ts @@ -0,0 +1,19 @@ +/** + * Auth Providers Export + * Central place to register and export all available providers + */ + +export { BaseAuthProvider } from './base' +export { GoogleAuthProvider } from './google' + +// Registry of all available providers +import { GoogleAuthProvider } from './google' + +// Instantiate providers +export const PROVIDERS = [new GoogleAuthProvider()] as const + +// Generate proper types from providers +export type ProviderType = (typeof PROVIDERS)[number]['id'] + +// Export types +export type { AuthProvider } from './types' diff --git a/extensions-web/src/shared/auth/providers/types.ts b/extensions-web/src/shared/auth/providers/types.ts new file mode 100644 index 0000000000..7536dae67a --- /dev/null +++ b/extensions-web/src/shared/auth/providers/types.ts @@ -0,0 +1,28 @@ +/** + * Provider Type Definitions + * Interfaces and types for authentication providers + */ + +import { AuthTokens } from '../types' + +export { AuthTokens } from '../types' +// Login URL response from API +export interface LoginUrlResponse { + object: string + url: string +} + +// Provider interface - all providers must implement this +export interface AuthProvider { + readonly id: string + readonly name: string + readonly icon: string + + // Provider-specific configuration + getLoginEndpoint(): string + getCallbackEndpoint(): string + + // OAuth flow methods + initiateLogin(): Promise + handleCallback(code: string, state?: string): Promise +} diff --git a/extensions-web/src/shared/auth/registry.ts b/extensions-web/src/shared/auth/registry.ts new file mode 100644 index 0000000000..8c3318bf6e --- /dev/null +++ b/extensions-web/src/shared/auth/registry.ts @@ -0,0 +1,25 @@ +/** + * Dynamic Auth Provider Registry + * Provider-agnostic registry that can be extended at runtime + */ + +import { PROVIDERS, type AuthProvider, type ProviderType } from './providers' + +export class AuthProviderRegistry { + private providers = new Map() + + constructor() { + // Register all available providers on initialization + for (const provider of PROVIDERS) { + this.providers.set(provider.id, provider) + } + } + + getProvider(providerId: ProviderType): AuthProvider | undefined { + return this.providers.get(providerId) + } + + getAllProviders(): AuthProvider[] { + return Array.from(this.providers.values()) + } +} diff --git a/extensions-web/src/shared/auth/service.ts b/extensions-web/src/shared/auth/service.ts new file mode 100644 index 0000000000..c9a15bb335 --- /dev/null +++ b/extensions-web/src/shared/auth/service.ts @@ -0,0 +1,424 @@ +/** + * Generic Authentication Service + * Handles authentication flows for any OAuth provider + */ + +declare const JAN_API_BASE: string + +import { User, AuthState, AuthBroadcastMessage } from './types' +import { + AUTH_STORAGE_KEYS, + AUTH_ENDPOINTS, + TOKEN_EXPIRY_BUFFER, + AUTH_EVENTS, +} from './const' +import { logoutUser, refreshToken, guestLogin } from './api' +import { AuthProviderRegistry } from './registry' +import { AuthBroadcast } from './broadcast' +import type { ProviderType } from './providers' + +const authProviderRegistry = new AuthProviderRegistry() + +export class JanAuthService { + private accessToken: string | null = null + private tokenExpiryTime: number = 0 + private refreshPromise: Promise | null = null + private authBroadcast: AuthBroadcast + private currentUser: User | null = null + private initPromise: Promise | null = null + + constructor() { + this.authBroadcast = new AuthBroadcast() + this.setupBroadcastHandlers() + this.initPromise = this.initialize().catch(console.error) + } + + /** + * Ensure initialization is complete before proceeding + */ + private async ensureInitialized(): Promise { + if (this.initPromise) { + await this.initPromise + this.initPromise = null + } + } + + /** + * Initialize the auth service + * Called on app load to check existing session + */ + async initialize(): Promise { + try { + if (!this.isAuthenticated()) { + // Not authenticated - ensure guest access + await this.ensureGuestAccess() + return + } + + // Authenticated - ensure we have a valid token + await this.refreshAccessToken() + } catch (error) { + console.error('Failed to initialize auth:', error) + } + } + + /** + * Start OAuth login flow with specified provider + */ + async loginWithProvider(providerId: ProviderType): Promise { + await this.ensureInitialized() + + const provider = authProviderRegistry.getProvider(providerId) + if (!provider) { + throw new Error(`Provider ${providerId} is not available`) + } + + try { + await provider.initiateLogin() + } catch (error) { + console.error(`Failed to initiate ${providerId} login:`, error) + throw error + } + } + + /** + * Handle OAuth callback for any provider + */ + async handleProviderCallback( + providerId: ProviderType, + code: string, + state?: string + ): Promise { + await this.ensureInitialized() + + const provider = authProviderRegistry.getProvider(providerId) + if (!provider) { + throw new Error(`Provider ${providerId} is not supported`) + } + + try { + // Use provider to handle the callback - this returns tokens + const tokens = await provider.handleCallback(code, state) + + // Store tokens and set authenticated state + this.accessToken = tokens.access_token + this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000 + this.setAuthProvider(providerId) + + this.authBroadcast.broadcastLogin() + } catch (error) { + console.error(`Failed to handle ${providerId} callback:`, error) + throw error + } + } + + /** + * Get a valid access token + * Handles both authenticated and guest tokens + */ + async getValidAccessToken(): Promise { + await this.ensureInitialized() + + if ( + this.accessToken && + Date.now() < this.tokenExpiryTime - TOKEN_EXPIRY_BUFFER + ) { + return this.accessToken + } + if (!this.refreshPromise) { + this.refreshPromise = this.refreshAccessToken().finally(() => { + this.refreshPromise = null + }) + } + + await this.refreshPromise + + if (!this.accessToken) { + throw new Error('Failed to obtain access token') + } + + return this.accessToken + } + + async refreshAccessToken(): Promise { + try { + const tokens = await refreshToken() + + this.accessToken = tokens.access_token + this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000 + } catch (error) { + console.error('Failed to refresh access token:', error) + if (error instanceof Error && error.message.includes('401')) { + await this.handleSessionExpired() + } + throw error + } + } + + /** + * Get current authenticated user + */ + async getCurrentUser(): Promise { + await this.ensureInitialized() + + const authType = this.getAuthState() + if (authType !== AuthState.AUTHENTICATED) { + return null + } + + if (this.currentUser) { + return this.currentUser + } + + const userProfile = await this.fetchUserProfile() + if (userProfile) { + const user: User = { + id: userProfile.id, + email: userProfile.email, + name: userProfile.name, + picture: userProfile.picture, + object: userProfile.object || 'user', + } + this.currentUser = user + } + + return this.currentUser + } + + /** + * Logout the current user + */ + async logout(): Promise { + await this.ensureInitialized() + + try { + const authType = this.getAuthState() + + if (authType === AuthState.AUTHENTICATED) { + await logoutUser() + } + + this.clearAuthState() + + this.authBroadcast.broadcastLogout() + + if (window.location.pathname !== '/') { + window.location.href = '/' + } + } catch (error) { + console.error('Logout failed:', error) + this.clearAuthState() + } + } + + /** + * Get enabled authentication providers + */ + getAllProviders(): Array<{ id: string; name: string; icon: string }> { + return authProviderRegistry.getAllProviders().map((provider) => ({ + id: provider.id, + name: provider.name, + icon: provider.icon, + })) + } + + /** + * Check if user is authenticated with any provider + */ + isAuthenticated(): boolean { + return this.getAuthState() === AuthState.AUTHENTICATED + } + + /** + * Check if user is authenticated with specific provider + */ + isAuthenticatedWithProvider(providerId: ProviderType): boolean { + const authType = this.getAuthState() + if (authType !== AuthState.AUTHENTICATED) { + return false + } + + return this.getAuthProvider() === providerId + } + + /** + * Get current auth type derived from provider + */ + getAuthState(): AuthState { + const provider = this.getAuthProvider() + if (!provider) return AuthState.UNAUTHENTICATED + if (provider === 'guest') return AuthState.GUEST + return AuthState.AUTHENTICATED + } + + /** + * Get auth headers for API requests + */ + async getAuthHeader(): Promise<{ Authorization: string }> { + await this.ensureInitialized() + + const token = await this.getValidAccessToken() + return { + Authorization: `Bearer ${token}`, + } + } + + /** + * Make authenticated API request + */ + async makeAuthenticatedRequest( + url: string, + options: RequestInit = {} + ): Promise { + await this.ensureInitialized() + + try { + const authHeader = await this.getAuthHeader() + + const response = await fetch(url, { + ...options, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...authHeader, + ...options.headers, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error( + `API request failed: ${response.status} ${response.statusText} - ${errorText}` + ) + } + + return response.json() + } catch (error) { + console.error('API request failed:', error) + throw error + } + } + + /** + * Get the broadcast channel for external listeners + */ + getBroadcastChannel(): BroadcastChannel | null { + return this.authBroadcast.getBroadcastChannel() + } + + /** + * Subscribe to auth events + */ + onAuthEvent( + callback: (event: MessageEvent<{ type: AuthBroadcastMessage }>) => void + ): () => void { + return this.authBroadcast.onAuthEvent(callback) + } + + /** + * Clear all auth state + */ + private clearAuthState(): void { + this.accessToken = null + this.tokenExpiryTime = 0 + this.currentUser = null + + localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER) + } + + /** + * Ensure guest access is available + */ + private async ensureGuestAccess(): Promise { + try { + this.setAuthProvider('guest') + if (!this.accessToken || Date.now() > this.tokenExpiryTime) { + const tokens = await guestLogin() + this.accessToken = tokens.access_token + this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000 + } + } catch (error) { + console.error('Failed to ensure guest access:', error) + // Remove provider (unauthenticated state) + localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER) + } + } + + /** + * Handle session expired + */ + private async handleSessionExpired(): Promise { + this.logout().catch(console.error) + this.ensureGuestAccess().catch(console.error) + } + + /** + * Setup broadcast event handlers + */ + private setupBroadcastHandlers(): void { + this.authBroadcast.onAuthEvent((event) => { + switch (event.data.type) { + case AUTH_EVENTS.LOGIN: + // Another tab logged in, refresh our state + this.initialize().catch(console.error) + break + + case AUTH_EVENTS.LOGOUT: + // Another tab logged out, clear our state + this.clearAuthState() + this.ensureGuestAccess().catch(console.error) + break + } + }) + } + + /** + * Get current auth provider + */ + getAuthProvider(): string | null { + return localStorage.getItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER) + } + + /** + * Set auth provider + */ + private setAuthProvider(provider: string): void { + localStorage.setItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER, provider) + } + + /** + * Fetch user profile from server + */ + private async fetchUserProfile(): Promise { + try { + return await this.makeAuthenticatedRequest( + `${JAN_API_BASE}${AUTH_ENDPOINTS.ME}` + ) + } catch (error) { + console.error('Failed to fetch user profile:', error) + if (error instanceof Error && error.message.includes('401')) { + // Authentication failed - handle session expiry + await this.handleSessionExpired() + return null + } + return null + } + } +} + +// Singleton instance management +declare global { + interface Window { + janAuthService?: JanAuthService + } +} + +/** + * Get or create the shared JanAuthService instance + */ +export function getSharedAuthService(): JanAuthService { + if (!window.janAuthService) { + window.janAuthService = new JanAuthService() + } + return window.janAuthService +} diff --git a/extensions-web/src/shared/auth/types.ts b/extensions-web/src/shared/auth/types.ts new file mode 100644 index 0000000000..65f2dd06a7 --- /dev/null +++ b/extensions-web/src/shared/auth/types.ts @@ -0,0 +1,31 @@ +/** + * Generic Authentication Types + * Provider-agnostic type definitions + */ + +import type { ProviderType } from './providers' +import { AUTH_EVENTS } from './const' + +export enum AuthState { + GUEST = 'guest', + AUTHENTICATED = 'authenticated', + UNAUTHENTICATED = 'unauthenticated', +} + +export type AuthType = ProviderType | 'guest' + +export interface AuthTokens { + access_token: string + expires_in: number + object: string +} + +export interface User { + id: string + email: string + name: string + object: string + picture?: string +} + +export type AuthBroadcastMessage = typeof AUTH_EVENTS[keyof typeof AUTH_EVENTS] diff --git a/extensions-web/src/shared/index.ts b/extensions-web/src/shared/index.ts index 92399c75fd..447e6e8e11 100644 --- a/extensions-web/src/shared/index.ts +++ b/extensions-web/src/shared/index.ts @@ -1,3 +1,3 @@ export { getSharedDB } from './db' -export { JanAuthService, getSharedAuthService } from './auth' -export type { AuthTokens, AuthResponse } from './auth' \ No newline at end of file + +export * from './auth' diff --git a/web-app/package.json b/web-app/package.json index 34224cee76..772c625d51 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -24,6 +24,7 @@ "@jan/extensions-web": "link:../extensions-web", "@janhq/core": "link:../core", "@radix-ui/react-accordion": "^1.2.10", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-hover-card": "^1.1.14", diff --git a/web-app/src/components/ui/avatar.tsx b/web-app/src/components/ui/avatar.tsx new file mode 100644 index 0000000000..7bedca62b4 --- /dev/null +++ b/web-app/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +const Avatar = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/web-app/src/components/ui/card.tsx b/web-app/src/components/ui/card.tsx new file mode 100644 index 0000000000..4902b398ba --- /dev/null +++ b/web-app/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 8ddfdbd366..17e17e60ca 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -21,6 +21,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' +import { AuthLoginButton } from '@/containers/auth/AuthLoginButton' +import { UserProfileMenu } from '@/containers/auth/UserProfileMenu' +import { useAuth } from '@/hooks/useAuth' import { useThreads } from '@/hooks/useThreads' @@ -31,8 +36,6 @@ import { DownloadManagement } from '@/containers/DownloadManegement' import { useSmallScreen } from '@/hooks/useMediaQuery' import { useClickOutside } from '@/hooks/useClickOutside' import { useDownloadStore } from '@/hooks/useDownloadStore' -import { PlatformFeatures } from '@/lib/platform/const' -import { PlatformFeature } from '@/lib/platform/types' import { DeleteAllThreadsDialog } from '@/containers/dialogs' const mainMenus = [ @@ -60,12 +63,19 @@ const mainMenus = [ route: route.settings.general, isEnabled: true, }, + { + title: 'common:authentication', + icon: null, + route: null, + isEnabled: PlatformFeatures[PlatformFeature.AUTHENTICATION], + }, ] const LeftPanel = () => { const { open, setLeftPanel } = useLeftPanel() const { t } = useTranslation() const [searchTerm, setSearchTerm] = useState('') + const { isAuthenticated } = useAuth() const isSmallScreen = useSmallScreen() const prevScreenSizeRef = useRef(null) @@ -413,8 +423,25 @@ const LeftPanel = () => {
{mainMenus.map((menu) => { if (!menu.isEnabled) { - return null + return null } + + // Handle authentication menu specially + if (menu.title === 'common:authentication') { + return ( +
+ {isAuthenticated ? ( + + ) : ( + + )} +
+ ) + } + + // Regular menu items must have route and icon + if (!menu.route || !menu.icon) return null + const isActive = currentPath.includes(route.settings.index) && menu.route.includes(route.settings.index) diff --git a/web-app/src/containers/auth/AuthLoginButton.tsx b/web-app/src/containers/auth/AuthLoginButton.tsx new file mode 100644 index 0000000000..2f27bf78d2 --- /dev/null +++ b/web-app/src/containers/auth/AuthLoginButton.tsx @@ -0,0 +1,84 @@ +/** + * Auth Login Button with Dropdown Menu + * Shows available authentication providers in a dropdown menu + */ + +import { useState } from 'react' +import { IconLogin, IconBrandGoogleFilled } from '@tabler/icons-react' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { useAuth } from '@/hooks/useAuth' +import { toast } from 'sonner' +import type { ProviderType } from '@jan/extensions-web' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +export const AuthLoginButton = () => { + const { t } = useTranslation() + const { getAllProviders, loginWithProvider } = useAuth() + const [isLoading, setIsLoading] = useState(false) + + const enabledProviders = getAllProviders() + + const handleProviderLogin = async (providerId: ProviderType) => { + try { + setIsLoading(true) + await loginWithProvider(providerId) + } catch (error) { + console.error('Failed to login with provider:', error) + toast.error(t('common:loginFailed')) + } finally { + setIsLoading(false) + } + } + + const getProviderIcon = (iconName: string) => { + switch (iconName) { + case 'IconBrandGoogleFilled': + return IconBrandGoogleFilled + default: + return IconLogin + } + } + + if (enabledProviders.length === 0) { + return null + } + + return ( + + + + + + {enabledProviders.map((provider) => { + const IconComponent = getProviderIcon(provider.icon) + return ( + handleProviderLogin(provider.id as ProviderType)} + disabled={isLoading} + className="gap-2" + > + + + {t('common:loginWith', { + provider: provider.name, + })} + + + ) + })} + + + ) +} diff --git a/web-app/src/containers/auth/UserProfileMenu.tsx b/web-app/src/containers/auth/UserProfileMenu.tsx new file mode 100644 index 0000000000..941bfa2479 --- /dev/null +++ b/web-app/src/containers/auth/UserProfileMenu.tsx @@ -0,0 +1,104 @@ +/** + * User Profile Menu Container + * Dropdown menu with user profile and logout options + */ + +import { useState } from 'react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { IconUser, IconLogout, IconChevronDown } from '@tabler/icons-react' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { useAuth } from '@/hooks/useAuth' +import { toast } from 'sonner' + +export const UserProfileMenu = () => { + const { t } = useTranslation() + const { user, isLoading, logout } = useAuth() + const [isLoggingOut, setIsLoggingOut] = useState(false) + + const handleLogout = async () => { + if (isLoggingOut) return + + try { + setIsLoggingOut(true) + await logout() + toast.success(t('common:loggedOut')) + } catch (error) { + console.error('Failed to logout:', error) + toast.error(t('common:logoutFailed')) + } finally { + setIsLoggingOut(false) + } + } + + if (isLoading || !user) { + return null + } + + const getInitials = (name: string) => { + const parts = name.split(' ') + if (parts.length >= 2) { + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase() + } + return name.slice(0, 2).toUpperCase() + } + + return ( + + + + + + +
+

{user.name}

+

+ {user.email} +

+
+
+ + + + {t('common:profile')} + + + + + + {isLoggingOut ? t('common:loggingOut') : t('common:logout')} + + +
+
+ ) +} diff --git a/web-app/src/hooks/useAuth.ts b/web-app/src/hooks/useAuth.ts new file mode 100644 index 0000000000..36c0a5e2f1 --- /dev/null +++ b/web-app/src/hooks/useAuth.ts @@ -0,0 +1,226 @@ +import { create } from 'zustand' +import { + type User, + type ProviderType, + JanAuthService, +} from '@jan/extensions-web' +import { PlatformFeature } from '@/lib/platform/types' +import { PlatformFeatures } from '@/lib/platform/const' + +interface AuthState { + // Auth service + authService: JanAuthService | null + setAuthService: (authService: JanAuthService | null) => void + + // Auth state + isAuthenticated: boolean + user: User | null + isLoading: boolean + + // State setters + setUser: (user: User | null) => void + setIsLoading: (isLoading: boolean) => void + + // Multi-provider auth actions + getAllProviders: () => Array<{ id: string; name: string; icon: string }> + loginWithProvider: (providerId: ProviderType) => Promise + handleProviderCallback: ( + providerId: ProviderType, + code: string, + state?: string + ) => Promise + isAuthenticatedWithProvider: (providerId: ProviderType) => boolean + + // Auth actions + logout: () => Promise + getCurrentUser: () => Promise + loadAuthState: () => Promise + subscribeToAuthEvents: (callback: (event: MessageEvent) => void) => () => void + + // Platform feature check + isAuthenticationEnabled: boolean +} + +const useAuthStore = create()((set, get) => ({ + // Auth service + authService: null, + setAuthService: (authService: JanAuthService | null) => set({ authService }), + + // Auth state + isAuthenticated: false, + user: null, + isLoading: true, + + // Platform feature check + isAuthenticationEnabled: + PlatformFeatures[PlatformFeature.AUTHENTICATION] || false, + + // State setters + setUser: (user: User | null) => + set(() => ({ + user, + isAuthenticated: user !== null, + })), + setIsLoading: (isLoading: boolean) => set({ isLoading }), + + // Multi-provider auth actions + getAllProviders: () => { + const { authService } = get() + if (!authService) { + return [] + } + return authService.getAllProviders() + }, + + loginWithProvider: async (providerId: ProviderType) => { + const { authService, isAuthenticationEnabled } = get() + if (!isAuthenticationEnabled || !authService) { + throw new Error('Authentication not available on this platform') + } + + await authService.loginWithProvider(providerId) + }, + + handleProviderCallback: async ( + providerId: ProviderType, + code: string, + state?: string + ) => { + const { authService, isAuthenticationEnabled, loadAuthState } = get() + if (!isAuthenticationEnabled || !authService) { + throw new Error('Authentication not available on this platform') + } + + await authService.handleProviderCallback(providerId, code, state) + // Reload auth state after successful callback + await loadAuthState() + }, + + isAuthenticatedWithProvider: (providerId: ProviderType) => { + const { authService } = get() + if (!authService) { + return false + } + + return authService.isAuthenticatedWithProvider(providerId) + }, + + logout: async () => { + const { authService, isAuthenticationEnabled } = get() + if (!isAuthenticationEnabled || !authService) { + throw new Error('Authentication not available on this platform') + } + + await authService.logout() + + // Update state after logout + set({ + user: null, + isAuthenticated: false, + }) + }, + + getCurrentUser: async (): Promise => { + const { authService, isAuthenticationEnabled } = get() + if (!isAuthenticationEnabled || !authService) { + return null + } + + try { + const profile = await authService.getCurrentUser() + set({ + user: profile, + isAuthenticated: profile !== null, + }) + return profile + } catch (error) { + console.error('Failed to get current user:', error) + return null + } + }, + + loadAuthState: async () => { + const { authService, isAuthenticationEnabled } = get() + if (!isAuthenticationEnabled || !authService) { + set({ isLoading: false }) + return + } + + try { + set({ isLoading: true }) + + // Check if user is authenticated with any provider + const isAuth = authService.isAuthenticated() + + // Load user profile if authenticated + if (isAuth) { + const profile = await authService.getCurrentUser() + set({ + user: profile, + isAuthenticated: profile !== null, + }) + } else { + set({ + user: null, + isAuthenticated: false, + }) + } + } catch (error) { + console.error('Failed to load auth state:', error) + set({ + user: null, + isAuthenticated: false, + }) + } finally { + set({ isLoading: false }) + } + }, + + subscribeToAuthEvents: (callback: (event: MessageEvent) => void) => { + const { authService } = get() + if (!authService || typeof authService.onAuthEvent !== 'function') { + return () => {} // Return no-op cleanup + } + + try { + return authService.onAuthEvent(callback) + } catch (error) { + console.warn('Failed to subscribe to auth events:', error) + return () => {} + } + }, +})) + +/** + * Hook to get auth state and actions for React components + */ +export const useAuth = () => { + const authState = useAuthStore() + return authState +} + +/** + * Global function to get auth store for non-React contexts + */ +export const getAuthStore = () => { + return useAuthStore.getState() +} + +/** + * Initialize the auth service in the store + * This should only be called from the AuthProvider after service initialization + */ +export const initializeAuthStore = async (authService: JanAuthService) => { + const store = useAuthStore.getState() + store.setAuthService(authService) + + // Load initial auth state + await store.loadAuthState() +} + +/** + * Check if auth service is initialized + */ +export const isAuthServiceInitialized = (): boolean => { + return useAuthStore.getState().authService !== null +} diff --git a/web-app/src/lib/platform/const.ts b/web-app/src/lib/platform/const.ts index c8beccf943..b4b1ac089f 100644 --- a/web-app/src/lib/platform/const.ts +++ b/web-app/src/lib/platform/const.ts @@ -52,4 +52,7 @@ export const PlatformFeatures: Record = { // Assistant functionality - disabled for web [PlatformFeature.ASSISTANTS]: isPlatformTauri(), + + // Authentication (Google OAuth) - enabled for web only + [PlatformFeature.AUTHENTICATION]: !isPlatformTauri(), } \ No newline at end of file diff --git a/web-app/src/lib/platform/types.ts b/web-app/src/lib/platform/types.ts index 64a8a2367f..15a45405a8 100644 --- a/web-app/src/lib/platform/types.ts +++ b/web-app/src/lib/platform/types.ts @@ -54,4 +54,7 @@ export enum PlatformFeature { // Assistant functionality (creation, editing, management) ASSISTANTS = 'assistants', + + // Authentication (Google OAuth, user profiles) + AUTHENTICATION = 'authentication', } diff --git a/web-app/src/locales/de-DE/common.json b/web-app/src/locales/de-DE/common.json index 4a7cd2b012..c0a55e1d97 100644 --- a/web-app/src/locales/de-DE/common.json +++ b/web-app/src/locales/de-DE/common.json @@ -27,6 +27,14 @@ "dataFolder": "Daten Ordner", "others": "Andere", "language": "Sprache", + "login": "Anmelden", + "loginWith": "Anmelden mit {{provider}}", + "loginFailed": "Anmeldung fehlgeschlagen", + "logout": "Abmelden", + "loggingOut": "Melde ab...", + "loggedOut": "Erfolgreich abgemeldet", + "logoutFailed": "Abmeldung fehlgeschlagen", + "profile": "Profil", "reset": "Zurücksetzen", "search": "Suchen", "name": "Name", diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index e5f5aa9f7b..ce66280005 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -27,6 +27,14 @@ "dataFolder": "Data Folder", "others": "Other", "language": "Language", + "login": "Log In", + "loginWith": "Log In With {{provider}}", + "loginFailed": "Failed To Log In", + "logout": "Log Out", + "loggingOut": "Logging Out...", + "loggedOut": "Successfully Logged Out", + "logoutFailed": "Failed To Log Out", + "profile": "Profile", "reset": "Reset", "search": "Search", "name": "Name", diff --git a/web-app/src/locales/id/common.json b/web-app/src/locales/id/common.json index 03f526bed9..c1f9838c6d 100644 --- a/web-app/src/locales/id/common.json +++ b/web-app/src/locales/id/common.json @@ -27,6 +27,14 @@ "dataFolder": "Folder Data", "others": "Lainnya", "language": "Bahasa", + "login": "Masuk", + "loginWith": "Masuk Dengan {{provider}}", + "loginFailed": "Gagal Masuk", + "logout": "Keluar", + "loggingOut": "Sedang Keluar...", + "loggedOut": "Berhasil Keluar", + "logoutFailed": "Gagal Keluar", + "profile": "Profil", "reset": "Setel Ulang", "search": "Cari", "name": "Nama", diff --git a/web-app/src/locales/pl/common.json b/web-app/src/locales/pl/common.json index c07edbe663..14fd6519eb 100644 --- a/web-app/src/locales/pl/common.json +++ b/web-app/src/locales/pl/common.json @@ -27,6 +27,14 @@ "dataFolder": "Katalog Danych", "others": "Inne", "language": "Język", + "login": "Zaloguj", + "loginWith": "Zaloguj Przez {{provider}}", + "loginFailed": "Logowanie Nie Powiodło Się", + "logout": "Wyloguj", + "loggingOut": "Wylogowywanie...", + "loggedOut": "Pomyślnie Wylogowano", + "logoutFailed": "Wylogowanie Nie Powiodło Się", + "profile": "Profil", "reset": "Przywróć", "search": "Szukaj", "name": "Nazwa", diff --git a/web-app/src/locales/vn/common.json b/web-app/src/locales/vn/common.json index 9bc2b25f0b..8a107a9a2d 100644 --- a/web-app/src/locales/vn/common.json +++ b/web-app/src/locales/vn/common.json @@ -27,6 +27,14 @@ "dataFolder": "Thư mục Dữ liệu", "others": "Khác", "language": "Ngôn ngữ", + "login": "Đăng Nhập", + "loginWith": "Đăng Nhập Bằng {{provider}}", + "loginFailed": "Đăng Nhập Thất Bại", + "logout": "Đăng Xuất", + "loggingOut": "Đang Đăng Xuất...", + "loggedOut": "Đăng Xuất Thành Công", + "logoutFailed": "Đăng Xuất Thất Bại", + "profile": "Hồ Sơ", "reset": "Đặt lại", "search": "Tìm kiếm", "name": "Tên", diff --git a/web-app/src/locales/zh-CN/common.json b/web-app/src/locales/zh-CN/common.json index a783d0f14e..ccabb60712 100644 --- a/web-app/src/locales/zh-CN/common.json +++ b/web-app/src/locales/zh-CN/common.json @@ -27,6 +27,14 @@ "dataFolder": "数据文件夹", "others": "其他", "language": "语言", + "login": "登录", + "loginWith": "使用{{provider}}登录", + "loginFailed": "登录失败", + "logout": "退出登录", + "loggingOut": "正在退出...", + "loggedOut": "成功退出登录", + "logoutFailed": "退出登录失败", + "profile": "个人资料", "reset": "重置", "search": "搜索", "name": "名称", diff --git a/web-app/src/locales/zh-TW/common.json b/web-app/src/locales/zh-TW/common.json index 0558196468..cb0a605104 100644 --- a/web-app/src/locales/zh-TW/common.json +++ b/web-app/src/locales/zh-TW/common.json @@ -27,6 +27,14 @@ "dataFolder": "資料夾", "others": "其他", "language": "語言", + "login": "登入", + "loginWith": "使用{{provider}}登入", + "loginFailed": "登入失敗", + "logout": "登出", + "loggingOut": "正在登出...", + "loggedOut": "成功登出", + "logoutFailed": "登出失敗", + "profile": "個人資料", "reset": "重設", "search": "搜尋", "name": "名稱", diff --git a/web-app/src/providers/AuthProvider.tsx b/web-app/src/providers/AuthProvider.tsx new file mode 100644 index 0000000000..6053e44f52 --- /dev/null +++ b/web-app/src/providers/AuthProvider.tsx @@ -0,0 +1,69 @@ +/** + * Authentication Provider + * Initializes the auth service and sets up event listeners + */ + +import { useEffect, useState, ReactNode } from 'react' +import { PlatformFeature } from '@/lib/platform/types' +import { PlatformFeatures } from '@/lib/platform/const' +import { initializeAuthStore, getAuthStore } from '@/hooks/useAuth' + +interface AuthProviderProps { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [isReady, setIsReady] = useState(false) + + // Check if authentication is enabled for this platform + const isAuthenticationEnabled = + PlatformFeatures[PlatformFeature.AUTHENTICATION] + + useEffect(() => { + if (!isAuthenticationEnabled) { + setIsReady(true) + return + } + + const initializeAuth = async () => { + try { + console.log('Initializing auth service...') + const { getSharedAuthService } = await import('@jan/extensions-web') + const authService = getSharedAuthService() + + await initializeAuthStore(authService) + console.log('Auth service initialized successfully') + + setIsReady(true) + } catch (error) { + console.error('Failed to initialize auth service:', error) + setIsReady(true) // Still render to show error state + } + } + + initializeAuth() + }, [isAuthenticationEnabled]) + + // Listen for auth state changes across tabs + useEffect(() => { + if (!isAuthenticationEnabled) return + + const handleAuthEvent = (event: MessageEvent) => { + // Listen for all auth events, not just login/logout + if (event.data?.type?.startsWith('auth:')) { + const authStore = getAuthStore() + authStore.loadAuthState() + } + } + + // Use the auth store's subscribeToAuthEvents method + const authStore = getAuthStore() + const cleanupAuthListener = authStore.subscribeToAuthEvents(handleAuthEvent) + + return () => { + cleanupAuthListener() + } + }, [isAuthenticationEnabled]) + + return <>{isReady && children} +} diff --git a/web-app/src/routeTree.gen.ts b/web-app/src/routeTree.gen.ts index 70aaa8fb2b..4322b0fd1e 100644 --- a/web-app/src/routeTree.gen.ts +++ b/web-app/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as LocalApiServerLogsImport } from './routes/local-api-server/log import { Route as HubModelIdImport } from './routes/hub/$modelId' import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index' import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName' +import { Route as AuthGoogleCallbackImport } from './routes/auth.google.callback' // Create/Update Routes @@ -148,6 +149,12 @@ const SettingsProvidersProviderNameRoute = getParentRoute: () => rootRoute, } as any) +const AuthGoogleCallbackRoute = AuthGoogleCallbackImport.update({ + id: '/auth/google/callback', + path: '/auth/google/callback', + getParentRoute: () => rootRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -271,6 +278,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HubIndexImport parentRoute: typeof rootRoute } + '/auth/google/callback': { + id: '/auth/google/callback' + path: '/auth/google/callback' + fullPath: '/auth/google/callback' + preLoaderRoute: typeof AuthGoogleCallbackImport + parentRoute: typeof rootRoute + } '/settings/providers/$providerName': { id: '/settings/providers/$providerName' path: '/settings/providers/$providerName' @@ -308,6 +322,7 @@ export interface FileRoutesByFullPath { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/hub': typeof HubIndexRoute + '/auth/google/callback': typeof AuthGoogleCallbackRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute '/settings/providers': typeof SettingsProvidersIndexRoute } @@ -330,6 +345,7 @@ export interface FileRoutesByTo { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/hub': typeof HubIndexRoute + '/auth/google/callback': typeof AuthGoogleCallbackRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute '/settings/providers': typeof SettingsProvidersIndexRoute } @@ -353,6 +369,7 @@ export interface FileRoutesById { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/hub/': typeof HubIndexRoute + '/auth/google/callback': typeof AuthGoogleCallbackRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute '/settings/providers/': typeof SettingsProvidersIndexRoute } @@ -377,6 +394,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/hub' + | '/auth/google/callback' | '/settings/providers/$providerName' | '/settings/providers' fileRoutesByTo: FileRoutesByTo @@ -398,6 +416,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/hub' + | '/auth/google/callback' | '/settings/providers/$providerName' | '/settings/providers' id: @@ -419,6 +438,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/hub/' + | '/auth/google/callback' | '/settings/providers/$providerName' | '/settings/providers/' fileRoutesById: FileRoutesById @@ -442,6 +462,7 @@ export interface RootRouteChildren { SettingsShortcutsRoute: typeof SettingsShortcutsRoute ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute HubIndexRoute: typeof HubIndexRoute + AuthGoogleCallbackRoute: typeof AuthGoogleCallbackRoute SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute } @@ -464,6 +485,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsShortcutsRoute: SettingsShortcutsRoute, ThreadsThreadIdRoute: ThreadsThreadIdRoute, HubIndexRoute: HubIndexRoute, + AuthGoogleCallbackRoute: AuthGoogleCallbackRoute, SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute, SettingsProvidersIndexRoute: SettingsProvidersIndexRoute, } @@ -495,6 +517,7 @@ export const routeTree = rootRoute "/settings/shortcuts", "/threads/$threadId", "/hub/", + "/auth/google/callback", "/settings/providers/$providerName", "/settings/providers/" ] @@ -550,6 +573,9 @@ export const routeTree = rootRoute "/hub/": { "filePath": "hub/index.tsx" }, + "/auth/google/callback": { + "filePath": "auth.google.callback.tsx" + }, "/settings/providers/$providerName": { "filePath": "settings/providers/$providerName.tsx" }, diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 710de43998..60df44035a 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -32,6 +32,7 @@ import GlobalError from '@/containers/GlobalError' import { GlobalEventHandler } from '@/providers/GlobalEventHandler' import ErrorDialog from '@/containers/dialogs/ErrorDialog' import { ServiceHubProvider } from '@/providers/ServiceHubProvider' +import { AuthProvider } from '@/providers/AuthProvider' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' @@ -206,9 +207,11 @@ function RootLayout() { - - - {isLocalAPIServerLogsRoute ? : } + + + + {isLocalAPIServerLogsRoute ? : } + {/* {isLocalAPIServerLogsRoute ? : } */} {/* */} diff --git a/web-app/src/routes/auth.google.callback.tsx b/web-app/src/routes/auth.google.callback.tsx new file mode 100644 index 0000000000..0de39a514a --- /dev/null +++ b/web-app/src/routes/auth.google.callback.tsx @@ -0,0 +1,84 @@ +/** + * Google OAuth Callback Route + * Handles the callback from Google OAuth flow + */ + +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useEffect } from 'react' +import { PlatformGuard } from '@/lib/platform/PlatformGuard' +import { PlatformFeature } from '@/lib/platform' +import { useAuth } from '@/hooks/useAuth' +import { toast } from 'sonner' + +export const Route = createFileRoute('/auth/google/callback')({ + component: () => ( + + + + ), +}) + +function GoogleCallbackRedirect() { + const navigate = useNavigate() + const { isAuthenticationEnabled, handleProviderCallback } = useAuth() + + useEffect(() => { + const handleCallback = async () => { + try { + if (!isAuthenticationEnabled) { + throw new Error('Authentication not available on this platform') + } + + // Check for error parameters first + const urlParams = new URLSearchParams(window.location.search) + const error = urlParams.get('error') + const errorDescription = urlParams.get('error_description') + + if (error) { + throw new Error(errorDescription || `OAuth error: ${error}`) + } + + // Extract the authorization code and state from URL parameters + const code = urlParams.get('code') + const state = urlParams.get('state') + + if (!code) { + throw new Error('No authorization code received from Google') + } + + // State is optional, don't require it + + // Handle successful callback with the code and optional state using generic method + await handleProviderCallback('google', code, state || undefined) + + toast.success('Successfully signed in!') + + // Redirect to home after authentication + navigate({ to: '/', replace: true }) + } catch (error) { + console.error('Google OAuth callback failed:', error) + + const message = + error instanceof Error ? error.message : 'Authentication failed' + toast.error(message) + + // Redirect to home on error (no login page) + navigate({ to: '/', replace: true }) + } + } + + handleCallback() + }, [isAuthenticationEnabled, handleProviderCallback, navigate]) + + return ( +
+
+
+

Signing you in...

+

+ Please wait while we complete your Google authentication. +

+
+
+ ) +} diff --git a/web-app/src/test/setup.ts b/web-app/src/test/setup.ts index a7f2451a4a..9fde8b66b2 100644 --- a/web-app/src/test/setup.ts +++ b/web-app/src/test/setup.ts @@ -24,6 +24,7 @@ vi.mock('@/lib/platform/const', () => ({ mcpServersSettings: true, extensionsSettings: true, assistants: true, + authentication: false, } })) diff --git a/yarn.lock b/yarn.lock index b66b7f4183..71da89b17b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3515,6 +3515,7 @@ __metadata: "@jan/extensions-web": "link:../extensions-web" "@janhq/core": "link:../core" "@radix-ui/react-accordion": "npm:^1.2.10" + "@radix-ui/react-avatar": "npm:^1.1.10" "@radix-ui/react-dialog": "npm:^1.1.14" "@radix-ui/react-dropdown-menu": "npm:^2.1.15" "@radix-ui/react-hover-card": "npm:^1.1.14" @@ -4183,6 +4184,29 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-avatar@npm:^1.1.10": + version: 1.1.10 + resolution: "@radix-ui/react-avatar@npm:1.1.10" + dependencies: + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-is-hydrated": "npm:0.1.0" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/9fb0cf9a9d0fdbeaa2efda476402fc09db2e6ff9cd9aa3ea1d315d9c9579840722a4833725cb196c455e0bd775dfe04221a4f6855685ce89d2133c42e2b07e5f + languageName: node + linkType: hard + "@radix-ui/react-collapsible@npm:1.1.11": version: 1.1.11 resolution: "@radix-ui/react-collapsible@npm:1.1.11" @@ -5026,6 +5050,21 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-is-hydrated@npm:0.1.0": + version: 0.1.0 + resolution: "@radix-ui/react-use-is-hydrated@npm:0.1.0" + dependencies: + use-sync-external-store: "npm:^1.5.0" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/635079bafe32829fc7405895154568ea94a22689b170489fd6d77668e4885e72ff71ed6d0ea3d602852841ef0f1927aa400fee2178d5dfbeb8bc9297da7d6498 + languageName: node + linkType: hard + "@radix-ui/react-use-layout-effect@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1" @@ -20368,6 +20407,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.5.0": + version: 1.5.0 + resolution: "use-sync-external-store@npm:1.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/1b8663515c0be34fa653feb724fdcce3984037c78dd4a18f68b2c8be55cc1a1084c578d5b75f158d41b5ddffc2bf5600766d1af3c19c8e329bb20af2ec6f52f4 + languageName: node + linkType: hard + "use@npm:^3.1.0": version: 3.1.1 resolution: "use@npm:3.1.1"