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 JanProviderWeb } from './jan-provider-web'
|
||||||
export { default as MCPExtensionWeb } from './mcp-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
|
// Re-export types
|
||||||
export type {
|
export type {
|
||||||
WebExtensionRegistry,
|
WebExtensionRegistry,
|
||||||
@ -17,9 +26,18 @@ export type {
|
|||||||
WebExtensionLoader,
|
WebExtensionLoader,
|
||||||
ConversationalWebModule,
|
ConversationalWebModule,
|
||||||
JanProviderWebModule,
|
JanProviderWebModule,
|
||||||
MCPWebModule
|
MCPWebModule,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
|
// Re-export auth types
|
||||||
|
export type {
|
||||||
|
User,
|
||||||
|
AuthTokens,
|
||||||
|
AuthProvider,
|
||||||
|
AuthProviderRegistry,
|
||||||
|
ProviderType,
|
||||||
|
} from './shared/auth'
|
||||||
|
|
||||||
// Extension registry for dynamic loading
|
// Extension registry for dynamic loading
|
||||||
export const WEB_EXTENSIONS: WebExtensionRegistry = {
|
export const WEB_EXTENSIONS: WebExtensionRegistry = {
|
||||||
'conversational-web': () => import('./conversational-web'),
|
'conversational-web': () => import('./conversational-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 { 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",
|
"@jan/extensions-web": "link:../extensions-web",
|
||||||
"@janhq/core": "link:../core",
|
"@janhq/core": "link:../core",
|
||||||
"@radix-ui/react-accordion": "^1.2.10",
|
"@radix-ui/react-accordion": "^1.2.10",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-hover-card": "^1.1.14",
|
"@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,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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'
|
import { useThreads } from '@/hooks/useThreads'
|
||||||
|
|
||||||
@ -31,8 +36,6 @@ import { DownloadManagement } from '@/containers/DownloadManegement'
|
|||||||
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
||||||
import { useClickOutside } from '@/hooks/useClickOutside'
|
import { useClickOutside } from '@/hooks/useClickOutside'
|
||||||
import { useDownloadStore } from '@/hooks/useDownloadStore'
|
import { useDownloadStore } from '@/hooks/useDownloadStore'
|
||||||
import { PlatformFeatures } from '@/lib/platform/const'
|
|
||||||
import { PlatformFeature } from '@/lib/platform/types'
|
|
||||||
import { DeleteAllThreadsDialog } from '@/containers/dialogs'
|
import { DeleteAllThreadsDialog } from '@/containers/dialogs'
|
||||||
|
|
||||||
const mainMenus = [
|
const mainMenus = [
|
||||||
@ -60,12 +63,19 @@ const mainMenus = [
|
|||||||
route: route.settings.general,
|
route: route.settings.general,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'common:authentication',
|
||||||
|
icon: null,
|
||||||
|
route: null,
|
||||||
|
isEnabled: PlatformFeatures[PlatformFeature.AUTHENTICATION],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const LeftPanel = () => {
|
const LeftPanel = () => {
|
||||||
const { open, setLeftPanel } = useLeftPanel()
|
const { open, setLeftPanel } = useLeftPanel()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
const isSmallScreen = useSmallScreen()
|
const isSmallScreen = useSmallScreen()
|
||||||
const prevScreenSizeRef = useRef<boolean | null>(null)
|
const prevScreenSizeRef = useRef<boolean | null>(null)
|
||||||
@ -413,8 +423,25 @@ const LeftPanel = () => {
|
|||||||
<div className="space-y-1 shrink-0 py-1 mt-2">
|
<div className="space-y-1 shrink-0 py-1 mt-2">
|
||||||
{mainMenus.map((menu) => {
|
{mainMenus.map((menu) => {
|
||||||
if (!menu.isEnabled) {
|
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 =
|
const isActive =
|
||||||
currentPath.includes(route.settings.index) &&
|
currentPath.includes(route.settings.index) &&
|
||||||
menu.route.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
|
// Assistant functionality - disabled for web
|
||||||
[PlatformFeature.ASSISTANTS]: isPlatformTauri(),
|
[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)
|
// Assistant functionality (creation, editing, management)
|
||||||
ASSISTANTS = 'assistants',
|
ASSISTANTS = 'assistants',
|
||||||
|
|
||||||
|
// Authentication (Google OAuth, user profiles)
|
||||||
|
AUTHENTICATION = 'authentication',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,14 @@
|
|||||||
"dataFolder": "Daten Ordner",
|
"dataFolder": "Daten Ordner",
|
||||||
"others": "Andere",
|
"others": "Andere",
|
||||||
"language": "Sprache",
|
"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",
|
"reset": "Zurücksetzen",
|
||||||
"search": "Suchen",
|
"search": "Suchen",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
@ -27,6 +27,14 @@
|
|||||||
"dataFolder": "Data Folder",
|
"dataFolder": "Data Folder",
|
||||||
"others": "Other",
|
"others": "Other",
|
||||||
"language": "Language",
|
"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",
|
"reset": "Reset",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
@ -27,6 +27,14 @@
|
|||||||
"dataFolder": "Folder Data",
|
"dataFolder": "Folder Data",
|
||||||
"others": "Lainnya",
|
"others": "Lainnya",
|
||||||
"language": "Bahasa",
|
"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",
|
"reset": "Setel Ulang",
|
||||||
"search": "Cari",
|
"search": "Cari",
|
||||||
"name": "Nama",
|
"name": "Nama",
|
||||||
|
|||||||
@ -27,6 +27,14 @@
|
|||||||
"dataFolder": "Katalog Danych",
|
"dataFolder": "Katalog Danych",
|
||||||
"others": "Inne",
|
"others": "Inne",
|
||||||
"language": "Język",
|
"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óć",
|
"reset": "Przywróć",
|
||||||
"search": "Szukaj",
|
"search": "Szukaj",
|
||||||
"name": "Nazwa",
|
"name": "Nazwa",
|
||||||
|
|||||||
@ -27,6 +27,14 @@
|
|||||||
"dataFolder": "Thư mục Dữ liệu",
|
"dataFolder": "Thư mục Dữ liệu",
|
||||||
"others": "Khác",
|
"others": "Khác",
|
||||||
"language": "Ngôn ngữ",
|
"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",
|
"reset": "Đặt lại",
|
||||||
"search": "Tìm kiếm",
|
"search": "Tìm kiếm",
|
||||||
"name": "Tên",
|
"name": "Tên",
|
||||||
|
|||||||
@ -27,6 +27,14 @@
|
|||||||
"dataFolder": "数据文件夹",
|
"dataFolder": "数据文件夹",
|
||||||
"others": "其他",
|
"others": "其他",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
|
"login": "登录",
|
||||||
|
"loginWith": "使用{{provider}}登录",
|
||||||
|
"loginFailed": "登录失败",
|
||||||
|
"logout": "退出登录",
|
||||||
|
"loggingOut": "正在退出...",
|
||||||
|
"loggedOut": "成功退出登录",
|
||||||
|
"logoutFailed": "退出登录失败",
|
||||||
|
"profile": "个人资料",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
|
|||||||
@ -27,6 +27,14 @@
|
|||||||
"dataFolder": "資料夾",
|
"dataFolder": "資料夾",
|
||||||
"others": "其他",
|
"others": "其他",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
|
"login": "登入",
|
||||||
|
"loginWith": "使用{{provider}}登入",
|
||||||
|
"loginFailed": "登入失敗",
|
||||||
|
"logout": "登出",
|
||||||
|
"loggingOut": "正在登出...",
|
||||||
|
"loggedOut": "成功登出",
|
||||||
|
"logoutFailed": "登出失敗",
|
||||||
|
"profile": "個人資料",
|
||||||
"reset": "重設",
|
"reset": "重設",
|
||||||
"search": "搜尋",
|
"search": "搜尋",
|
||||||
"name": "名稱",
|
"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 HubModelIdImport } from './routes/hub/$modelId'
|
||||||
import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index'
|
import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index'
|
||||||
import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName'
|
import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName'
|
||||||
|
import { Route as AuthGoogleCallbackImport } from './routes/auth.google.callback'
|
||||||
|
|
||||||
// Create/Update Routes
|
// Create/Update Routes
|
||||||
|
|
||||||
@ -148,6 +149,12 @@ const SettingsProvidersProviderNameRoute =
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const AuthGoogleCallbackRoute = AuthGoogleCallbackImport.update({
|
||||||
|
id: '/auth/google/callback',
|
||||||
|
path: '/auth/google/callback',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
// Populate the FileRoutesByPath interface
|
// Populate the FileRoutesByPath interface
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@ -271,6 +278,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof HubIndexImport
|
preLoaderRoute: typeof HubIndexImport
|
||||||
parentRoute: typeof rootRoute
|
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': {
|
'/settings/providers/$providerName': {
|
||||||
id: '/settings/providers/$providerName'
|
id: '/settings/providers/$providerName'
|
||||||
path: '/settings/providers/$providerName'
|
path: '/settings/providers/$providerName'
|
||||||
@ -308,6 +322,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
'/hub': typeof HubIndexRoute
|
'/hub': typeof HubIndexRoute
|
||||||
|
'/auth/google/callback': typeof AuthGoogleCallbackRoute
|
||||||
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
|
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
|
||||||
'/settings/providers': typeof SettingsProvidersIndexRoute
|
'/settings/providers': typeof SettingsProvidersIndexRoute
|
||||||
}
|
}
|
||||||
@ -330,6 +345,7 @@ export interface FileRoutesByTo {
|
|||||||
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
'/hub': typeof HubIndexRoute
|
'/hub': typeof HubIndexRoute
|
||||||
|
'/auth/google/callback': typeof AuthGoogleCallbackRoute
|
||||||
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
|
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
|
||||||
'/settings/providers': typeof SettingsProvidersIndexRoute
|
'/settings/providers': typeof SettingsProvidersIndexRoute
|
||||||
}
|
}
|
||||||
@ -353,6 +369,7 @@ export interface FileRoutesById {
|
|||||||
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
'/hub/': typeof HubIndexRoute
|
'/hub/': typeof HubIndexRoute
|
||||||
|
'/auth/google/callback': typeof AuthGoogleCallbackRoute
|
||||||
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
|
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
|
||||||
'/settings/providers/': typeof SettingsProvidersIndexRoute
|
'/settings/providers/': typeof SettingsProvidersIndexRoute
|
||||||
}
|
}
|
||||||
@ -377,6 +394,7 @@ export interface FileRouteTypes {
|
|||||||
| '/settings/shortcuts'
|
| '/settings/shortcuts'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
| '/hub'
|
| '/hub'
|
||||||
|
| '/auth/google/callback'
|
||||||
| '/settings/providers/$providerName'
|
| '/settings/providers/$providerName'
|
||||||
| '/settings/providers'
|
| '/settings/providers'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@ -398,6 +416,7 @@ export interface FileRouteTypes {
|
|||||||
| '/settings/shortcuts'
|
| '/settings/shortcuts'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
| '/hub'
|
| '/hub'
|
||||||
|
| '/auth/google/callback'
|
||||||
| '/settings/providers/$providerName'
|
| '/settings/providers/$providerName'
|
||||||
| '/settings/providers'
|
| '/settings/providers'
|
||||||
id:
|
id:
|
||||||
@ -419,6 +438,7 @@ export interface FileRouteTypes {
|
|||||||
| '/settings/shortcuts'
|
| '/settings/shortcuts'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
| '/hub/'
|
| '/hub/'
|
||||||
|
| '/auth/google/callback'
|
||||||
| '/settings/providers/$providerName'
|
| '/settings/providers/$providerName'
|
||||||
| '/settings/providers/'
|
| '/settings/providers/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@ -442,6 +462,7 @@ export interface RootRouteChildren {
|
|||||||
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
|
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
|
||||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
||||||
HubIndexRoute: typeof HubIndexRoute
|
HubIndexRoute: typeof HubIndexRoute
|
||||||
|
AuthGoogleCallbackRoute: typeof AuthGoogleCallbackRoute
|
||||||
SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute
|
SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute
|
||||||
SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute
|
SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute
|
||||||
}
|
}
|
||||||
@ -464,6 +485,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
SettingsShortcutsRoute: SettingsShortcutsRoute,
|
SettingsShortcutsRoute: SettingsShortcutsRoute,
|
||||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
||||||
HubIndexRoute: HubIndexRoute,
|
HubIndexRoute: HubIndexRoute,
|
||||||
|
AuthGoogleCallbackRoute: AuthGoogleCallbackRoute,
|
||||||
SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute,
|
SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute,
|
||||||
SettingsProvidersIndexRoute: SettingsProvidersIndexRoute,
|
SettingsProvidersIndexRoute: SettingsProvidersIndexRoute,
|
||||||
}
|
}
|
||||||
@ -495,6 +517,7 @@ export const routeTree = rootRoute
|
|||||||
"/settings/shortcuts",
|
"/settings/shortcuts",
|
||||||
"/threads/$threadId",
|
"/threads/$threadId",
|
||||||
"/hub/",
|
"/hub/",
|
||||||
|
"/auth/google/callback",
|
||||||
"/settings/providers/$providerName",
|
"/settings/providers/$providerName",
|
||||||
"/settings/providers/"
|
"/settings/providers/"
|
||||||
]
|
]
|
||||||
@ -550,6 +573,9 @@ export const routeTree = rootRoute
|
|||||||
"/hub/": {
|
"/hub/": {
|
||||||
"filePath": "hub/index.tsx"
|
"filePath": "hub/index.tsx"
|
||||||
},
|
},
|
||||||
|
"/auth/google/callback": {
|
||||||
|
"filePath": "auth.google.callback.tsx"
|
||||||
|
},
|
||||||
"/settings/providers/$providerName": {
|
"/settings/providers/$providerName": {
|
||||||
"filePath": "settings/providers/$providerName.tsx"
|
"filePath": "settings/providers/$providerName.tsx"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import GlobalError from '@/containers/GlobalError'
|
|||||||
import { GlobalEventHandler } from '@/providers/GlobalEventHandler'
|
import { GlobalEventHandler } from '@/providers/GlobalEventHandler'
|
||||||
import ErrorDialog from '@/containers/dialogs/ErrorDialog'
|
import ErrorDialog from '@/containers/dialogs/ErrorDialog'
|
||||||
import { ServiceHubProvider } from '@/providers/ServiceHubProvider'
|
import { ServiceHubProvider } from '@/providers/ServiceHubProvider'
|
||||||
|
import { AuthProvider } from '@/providers/AuthProvider'
|
||||||
import { PlatformFeatures } from '@/lib/platform/const'
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
import { PlatformFeature } from '@/lib/platform/types'
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
@ -206,9 +207,11 @@ function RootLayout() {
|
|||||||
<ToasterProvider />
|
<ToasterProvider />
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
<ExtensionProvider>
|
<ExtensionProvider>
|
||||||
<DataProvider />
|
<AuthProvider>
|
||||||
<GlobalEventHandler />
|
<DataProvider />
|
||||||
{isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />}
|
<GlobalEventHandler />
|
||||||
|
{isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />}
|
||||||
|
</AuthProvider>
|
||||||
</ExtensionProvider>
|
</ExtensionProvider>
|
||||||
{/* {isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />} */}
|
{/* {isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />} */}
|
||||||
{/* <TanStackRouterDevtools position="bottom-right" /> */}
|
{/* <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,
|
mcpServersSettings: true,
|
||||||
extensionsSettings: true,
|
extensionsSettings: true,
|
||||||
assistants: true,
|
assistants: true,
|
||||||
|
authentication: false,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
48
yarn.lock
48
yarn.lock
@ -3515,6 +3515,7 @@ __metadata:
|
|||||||
"@jan/extensions-web": "link:../extensions-web"
|
"@jan/extensions-web": "link:../extensions-web"
|
||||||
"@janhq/core": "link:../core"
|
"@janhq/core": "link:../core"
|
||||||
"@radix-ui/react-accordion": "npm:^1.2.10"
|
"@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-dialog": "npm:^1.1.14"
|
||||||
"@radix-ui/react-dropdown-menu": "npm:^2.1.15"
|
"@radix-ui/react-dropdown-menu": "npm:^2.1.15"
|
||||||
"@radix-ui/react-hover-card": "npm:^1.1.14"
|
"@radix-ui/react-hover-card": "npm:^1.1.14"
|
||||||
@ -4183,6 +4184,29 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@radix-ui/react-collapsible@npm:1.1.11":
|
||||||
version: 1.1.11
|
version: 1.1.11
|
||||||
resolution: "@radix-ui/react-collapsible@npm:1.1.11"
|
resolution: "@radix-ui/react-collapsible@npm:1.1.11"
|
||||||
@ -5026,6 +5050,21 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@radix-ui/react-use-layout-effect@npm:1.1.1":
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1"
|
resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1"
|
||||||
@ -20368,6 +20407,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"use@npm:^3.1.0":
|
||||||
version: 3.1.1
|
version: 3.1.1
|
||||||
resolution: "use@npm:3.1.1"
|
resolution: "use@npm:3.1.1"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user