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:
parent
973f77cdc6
commit
0f85fce6ef
@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
68
extensions-web/src/shared/auth/api.ts
Normal file
68
extensions-web/src/shared/auth/api.ts
Normal 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>
|
||||
}
|
||||
92
extensions-web/src/shared/auth/broadcast.ts
Normal file
92
extensions-web/src/shared/auth/broadcast.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
extensions-web/src/shared/auth/const.ts
Normal file
29
extensions-web/src/shared/auth/const.ts
Normal 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
|
||||
10
extensions-web/src/shared/auth/index.ts
Normal file
10
extensions-web/src/shared/auth/index.ts
Normal 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'
|
||||
49
extensions-web/src/shared/auth/providers/api.ts
Normal file
49
extensions-web/src/shared/auth/providers/api.ts
Normal 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>
|
||||
}
|
||||
39
extensions-web/src/shared/auth/providers/base.ts
Normal file
39
extensions-web/src/shared/auth/providers/base.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
extensions-web/src/shared/auth/providers/google.ts
Normal file
20
extensions-web/src/shared/auth/providers/google.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
19
extensions-web/src/shared/auth/providers/index.ts
Normal file
19
extensions-web/src/shared/auth/providers/index.ts
Normal 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'
|
||||
28
extensions-web/src/shared/auth/providers/types.ts
Normal file
28
extensions-web/src/shared/auth/providers/types.ts
Normal 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>
|
||||
}
|
||||
25
extensions-web/src/shared/auth/registry.ts
Normal file
25
extensions-web/src/shared/auth/registry.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
424
extensions-web/src/shared/auth/service.ts
Normal file
424
extensions-web/src/shared/auth/service.ts
Normal 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
|
||||
}
|
||||
31
extensions-web/src/shared/auth/types.ts
Normal file
31
extensions-web/src/shared/auth/types.ts
Normal 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]
|
||||
@ -1,3 +1,3 @@
|
||||
export { getSharedDB } from './db'
|
||||
export { JanAuthService, getSharedAuthService } from './auth'
|
||||
export type { AuthTokens, AuthResponse } from './auth'
|
||||
|
||||
export * from './auth'
|
||||
|
||||
@ -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",
|
||||
|
||||
48
web-app/src/components/ui/avatar.tsx
Normal file
48
web-app/src/components/ui/avatar.tsx
Normal 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 }
|
||||
79
web-app/src/components/ui/card.tsx
Normal file
79
web-app/src/components/ui/card.tsx
Normal 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 }
|
||||
@ -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)
|
||||
|
||||
84
web-app/src/containers/auth/AuthLoginButton.tsx
Normal file
84
web-app/src/containers/auth/AuthLoginButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
web-app/src/containers/auth/UserProfileMenu.tsx
Normal file
104
web-app/src/containers/auth/UserProfileMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
226
web-app/src/hooks/useAuth.ts
Normal file
226
web-app/src/hooks/useAuth.ts
Normal 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
|
||||
}
|
||||
@ -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(),
|
||||
}
|
||||
@ -54,4 +54,7 @@ export enum PlatformFeature {
|
||||
|
||||
// Assistant functionality (creation, editing, management)
|
||||
ASSISTANTS = 'assistants',
|
||||
|
||||
// Authentication (Google OAuth, user profiles)
|
||||
AUTHENTICATION = 'authentication',
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -27,6 +27,14 @@
|
||||
"dataFolder": "数据文件夹",
|
||||
"others": "其他",
|
||||
"language": "语言",
|
||||
"login": "登录",
|
||||
"loginWith": "使用{{provider}}登录",
|
||||
"loginFailed": "登录失败",
|
||||
"logout": "退出登录",
|
||||
"loggingOut": "正在退出...",
|
||||
"loggedOut": "成功退出登录",
|
||||
"logoutFailed": "退出登录失败",
|
||||
"profile": "个人资料",
|
||||
"reset": "重置",
|
||||
"search": "搜索",
|
||||
"name": "名称",
|
||||
|
||||
@ -27,6 +27,14 @@
|
||||
"dataFolder": "資料夾",
|
||||
"others": "其他",
|
||||
"language": "語言",
|
||||
"login": "登入",
|
||||
"loginWith": "使用{{provider}}登入",
|
||||
"loginFailed": "登入失敗",
|
||||
"logout": "登出",
|
||||
"loggingOut": "正在登出...",
|
||||
"loggedOut": "成功登出",
|
||||
"logoutFailed": "登出失敗",
|
||||
"profile": "個人資料",
|
||||
"reset": "重設",
|
||||
"search": "搜尋",
|
||||
"name": "名稱",
|
||||
|
||||
69
web-app/src/providers/AuthProvider.tsx
Normal file
69
web-app/src/providers/AuthProvider.tsx
Normal 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}</>
|
||||
}
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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" /> */}
|
||||
|
||||
84
web-app/src/routes/auth.google.callback.tsx
Normal file
84
web-app/src/routes/auth.google.callback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -24,6 +24,7 @@ vi.mock('@/lib/platform/const', () => ({
|
||||
mcpServersSettings: true,
|
||||
extensionsSettings: true,
|
||||
assistants: true,
|
||||
authentication: false,
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
48
yarn.lock
48
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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user