feat: add auth + google auth provider for web (#6505)

* handle google auth

* fix lint

* fix auto login button type

* update i18 language + userprofilemenu position

* minor api rename for consistency
This commit is contained in:
Dinh Long Nguyen 2025-09-18 11:11:14 +07:00 committed by GitHub
parent 973f77cdc6
commit 0f85fce6ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1724 additions and 229 deletions

View File

@ -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'),
}
}

View File

@ -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<AuthTokens> {
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<AuthTokens> {
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<string> {
// 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<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
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
}

View File

@ -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<void> {
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<AuthTokens> {
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<AuthTokens>
}
/**
* Refresh token (works for both guest and authenticated users)
*/
export async function refreshToken(): Promise<AuthTokens> {
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<AuthTokens>
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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'

View File

@ -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<LoginUrlResponse> {
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<LoginUrlResponse>
}
export async function handleOAuthCallback(
endpoint: string,
code: string,
state?: string
): Promise<AuthTokens> {
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<AuthTokens>
}

View File

@ -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<void> {
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<AuthTokens> {
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
}
}
}

View File

@ -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'
}
}

View File

@ -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'

View File

@ -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<void>
handleCallback(code: string, state?: string): Promise<AuthTokens>
}

View File

@ -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<ProviderType, AuthProvider>()
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())
}
}

View File

@ -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<void> | null = null
private authBroadcast: AuthBroadcast
private currentUser: User | null = null
private initPromise: Promise<void> | 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<void> {
if (this.initPromise) {
await this.initPromise
this.initPromise = null
}
}
/**
* Initialize the auth service
* Called on app load to check existing session
*/
async initialize(): Promise<void> {
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<void> {
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<void> {
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<string> {
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<void> {
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<User | null> {
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<void> {
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<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
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<void> {
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<void> {
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<User | null> {
try {
return await this.makeAuthenticatedRequest<User>(
`${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
}

View File

@ -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]

View File

@ -1,3 +1,3 @@
export { getSharedDB } from './db'
export { JanAuthService, getSharedAuthService } from './auth'
export type { AuthTokens, AuthResponse } from './auth'
export * from './auth'

View File

@ -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",

View File

@ -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<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,79 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -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<boolean | null>(null)
@ -413,8 +423,25 @@ const LeftPanel = () => {
<div className="space-y-1 shrink-0 py-1 mt-2">
{mainMenus.map((menu) => {
if (!menu.isEnabled) {
return null
return null
}
// Handle authentication menu specially
if (menu.title === 'common:authentication') {
return (
<div key={menu.title}>
{isAuthenticated ? (
<UserProfileMenu />
) : (
<AuthLoginButton />
)}
</div>
)
}
// 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)

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={isLoading}
className="flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded w-full"
>
<IconLogin size={18} className="text-left-panel-fg/70" />
<span className="font-medium text-left-panel-fg/90">{t('common:login')}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-48">
{enabledProviders.map((provider) => {
const IconComponent = getProviderIcon(provider.icon)
return (
<DropdownMenuItem
key={provider.id}
onClick={() => handleProviderLogin(provider.id as ProviderType)}
disabled={isLoading}
className="gap-2"
>
<IconComponent size={16} />
<span className="text-sm text-left-panel-fg/90">
{t('common:loginWith', {
provider: provider.name,
})}
</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
size="sm"
className="w-full justify-between gap-2 px-2"
>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
{user.picture && (
<AvatarImage src={user.picture} alt={user.name} />
)}
<AvatarFallback className="text-xs">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
<span className="truncate text-sm">{user.name}</span>
</div>
<IconChevronDown size={14} className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2">
<IconUser size={16} />
<span>{t('common:profile')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
disabled={isLoggingOut}
className="gap-2 text-destructive focus:text-destructive"
>
<IconLogout size={16} />
<span>
{isLoggingOut ? t('common:loggingOut') : t('common:logout')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -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<void>
handleProviderCallback: (
providerId: ProviderType,
code: string,
state?: string
) => Promise<void>
isAuthenticatedWithProvider: (providerId: ProviderType) => boolean
// Auth actions
logout: () => Promise<void>
getCurrentUser: () => Promise<User | null>
loadAuthState: () => Promise<void>
subscribeToAuthEvents: (callback: (event: MessageEvent) => void) => () => void
// Platform feature check
isAuthenticationEnabled: boolean
}
const useAuthStore = create<AuthState>()((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<User | null> => {
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
}

View File

@ -52,4 +52,7 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
// Assistant functionality - disabled for web
[PlatformFeature.ASSISTANTS]: isPlatformTauri(),
// Authentication (Google OAuth) - enabled for web only
[PlatformFeature.AUTHENTICATION]: !isPlatformTauri(),
}

View File

@ -54,4 +54,7 @@ export enum PlatformFeature {
// Assistant functionality (creation, editing, management)
ASSISTANTS = 'assistants',
// Authentication (Google OAuth, user profiles)
AUTHENTICATION = 'authentication',
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -27,6 +27,14 @@
"dataFolder": "数据文件夹",
"others": "其他",
"language": "语言",
"login": "登录",
"loginWith": "使用{{provider}}登录",
"loginFailed": "登录失败",
"logout": "退出登录",
"loggingOut": "正在退出...",
"loggedOut": "成功退出登录",
"logoutFailed": "退出登录失败",
"profile": "个人资料",
"reset": "重置",
"search": "搜索",
"name": "名称",

View File

@ -27,6 +27,14 @@
"dataFolder": "資料夾",
"others": "其他",
"language": "語言",
"login": "登入",
"loginWith": "使用{{provider}}登入",
"loginFailed": "登入失敗",
"logout": "登出",
"loggingOut": "正在登出...",
"loggedOut": "成功登出",
"logoutFailed": "登出失敗",
"profile": "個人資料",
"reset": "重設",
"search": "搜尋",
"name": "名稱",

View File

@ -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}</>
}

View File

@ -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"
},

View File

@ -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() {
<ToasterProvider />
<TranslationProvider>
<ExtensionProvider>
<DataProvider />
<GlobalEventHandler />
{isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />}
<AuthProvider>
<DataProvider />
<GlobalEventHandler />
{isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />}
</AuthProvider>
</ExtensionProvider>
{/* {isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />} */}
{/* <TanStackRouterDevtools position="bottom-right" /> */}

View File

@ -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: () => (
<PlatformGuard feature={PlatformFeature.AUTHENTICATION}>
<GoogleCallbackRedirect />
</PlatformGuard>
),
})
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 (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<h2 className="text-xl font-semibold mb-2">Signing you in...</h2>
<p className="text-muted-foreground">
Please wait while we complete your Google authentication.
</p>
</div>
</div>
)
}

View File

@ -24,6 +24,7 @@ vi.mock('@/lib/platform/const', () => ({
mcpServersSettings: true,
extensionsSettings: true,
assistants: true,
authentication: false,
}
}))

View File

@ -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"