Merge pull request #6507 from menloresearch/dev
Sync dev with dev web (google auth)
@ -64,7 +64,7 @@ jobs:
|
||||
- name: Install Tauri dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2
|
||||
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2 libayatana-appindicator3-dev
|
||||
|
||||
- name: Update app version
|
||||
run: |
|
||||
|
||||
@ -101,7 +101,7 @@ jobs:
|
||||
- name: Install Tauri dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2
|
||||
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2 libayatana-appindicator3-dev
|
||||
|
||||
- name: Update app version base public_provider
|
||||
run: |
|
||||
|
||||
@ -682,4 +682,18 @@ docs/guides/fine-tuning/what-models-can-be-fine-tuned/ /docs 302
|
||||
/docs/server-installation/onprem /docs/desktop 302
|
||||
/docs/server-installation/aws /docs/desktop 302
|
||||
/docs/server-installation/gcp /docs/desktop 302
|
||||
/docs/server-installation/azure /docs/desktop 302
|
||||
/docs/server-installation/azure /docs/desktop 302
|
||||
/about /docs 302
|
||||
/api-server /docs/api-server 302
|
||||
/cdn-cgi/l/email-protection 302
|
||||
/docs/built-in/tensorrt-llm 302
|
||||
/docs/desktop/beta /docs 302
|
||||
/docs/docs/data-folder /docs/data-folder 302
|
||||
/docs/docs/desktop/linux /docs/desktop/linux 302
|
||||
/docs/docs/troubleshooting /docs/troubleshooting 302
|
||||
/docs/local-engines/llama-cpp 302
|
||||
/docs/models/model-parameters 302
|
||||
/mcp /docs/mcp 302
|
||||
/quickstart /docs/quickstart 302
|
||||
/server-examples/continue-dev /docs/server-examples/continue-dev 302
|
||||
|
||||
|
||||
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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'
|
||||
|
||||
@ -517,41 +517,41 @@ __metadata:
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=c5357d&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension"
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=fcb200&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
checksum: 10c0/95e2ec1f1213d604730f5c9c381c80840402b00a9649039d1a9754ee3efa13e224e4ca39ea094aab5751f3f2ace1860c7640769e66b191b8c56998fd5f2ba5b9
|
||||
checksum: 10c0/603e79794614f861a9cf5693a4bbc480a62242309a6cb94a97c81e31518032c7462b2edad93a4380a18110f817ab15d85dc91a7924fd6e103a3462f6915ee368
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=c5357d&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension"
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=fcb200&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
checksum: 10c0/95e2ec1f1213d604730f5c9c381c80840402b00a9649039d1a9754ee3efa13e224e4ca39ea094aab5751f3f2ace1860c7640769e66b191b8c56998fd5f2ba5b9
|
||||
checksum: 10c0/603e79794614f861a9cf5693a4bbc480a62242309a6cb94a97c81e31518032c7462b2edad93a4380a18110f817ab15d85dc91a7924fd6e103a3462f6915ee368
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=c5357d&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension"
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=fcb200&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
checksum: 10c0/95e2ec1f1213d604730f5c9c381c80840402b00a9649039d1a9754ee3efa13e224e4ca39ea094aab5751f3f2ace1860c7640769e66b191b8c56998fd5f2ba5b9
|
||||
checksum: 10c0/603e79794614f861a9cf5693a4bbc480a62242309a6cb94a97c81e31518032c7462b2edad93a4380a18110f817ab15d85dc91a7924fd6e103a3462f6915ee368
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=c5357d&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=fcb200&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
checksum: 10c0/95e2ec1f1213d604730f5c9c381c80840402b00a9649039d1a9754ee3efa13e224e4ca39ea094aab5751f3f2ace1860c7640769e66b191b8c56998fd5f2ba5b9
|
||||
checksum: 10c0/603e79794614f861a9cf5693a4bbc480a62242309a6cb94a97c81e31518032c7462b2edad93a4380a18110f817ab15d85dc91a7924fd6e103a3462f6915ee368
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
[env]
|
||||
# workaround needed to prevent `STATUS_ENTRYPOINT_NOT_FOUND` error in tests
|
||||
# see https://github.com/tauri-apps/tauri/pull/4383#issuecomment-1212221864
|
||||
__TAURI_WORKSPACE__ = "true"
|
||||
__TAURI_WORKSPACE__ = "true"
|
||||
ENABLE_SYSTEM_TRAY_ICON = "false"
|
||||
|
||||
@ -20,6 +20,7 @@ default = [
|
||||
"tauri/x11",
|
||||
"tauri/protocol-asset",
|
||||
"tauri/macos-private-api",
|
||||
"tauri/tray-icon",
|
||||
"tauri/test",
|
||||
]
|
||||
test-tauri = [
|
||||
@ -27,6 +28,7 @@ test-tauri = [
|
||||
"tauri/x11",
|
||||
"tauri/protocol-asset",
|
||||
"tauri/macos-private-api",
|
||||
"tauri/tray-icon",
|
||||
"tauri/test",
|
||||
]
|
||||
|
||||
|
||||
@ -296,7 +296,6 @@ pub async fn cancel_tool_call(
|
||||
pub async fn get_mcp_configs(app: AppHandle) -> Result<String, String> {
|
||||
let mut path = get_jan_data_folder_path(app);
|
||||
path.push("mcp_config.json");
|
||||
log::info!("read mcp configs, path: {:?}", path);
|
||||
|
||||
// Create default empty config if file doesn't exist
|
||||
if !path.exists() {
|
||||
|
||||
@ -8,6 +8,12 @@ pub const MCP_BACKOFF_MULTIPLIER: f64 = 2.0; // Double the delay each time
|
||||
|
||||
pub const DEFAULT_MCP_CONFIG: &str = r#"{
|
||||
"mcpServers": {
|
||||
"exa": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "exa-mcp-server"],
|
||||
"env": { "EXA_API_KEY": "YOUR_EXA_API_KEY_HERE" },
|
||||
"active": false
|
||||
},
|
||||
"browsermcp": {
|
||||
"command": "npx",
|
||||
"args": ["@browsermcp/mcp"],
|
||||
|
||||
@ -5,7 +5,11 @@ use std::{
|
||||
path::PathBuf,
|
||||
};
|
||||
use tar::Archive;
|
||||
use tauri::{App, Emitter, Manager};
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
|
||||
App, Emitter, Manager,
|
||||
};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
// use tokio::sync::Mutex;
|
||||
// use tokio::time::{sleep, Duration}; // Using tokio::sync::Mutex
|
||||
@ -82,7 +86,6 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), Stri
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map_or(false, |ext| ext == "tgz") {
|
||||
log::info!("Installing extension from {:?}", path);
|
||||
let tar_gz = File::open(&path).map_err(|e| e.to_string())?;
|
||||
let gz_decoder = GzDecoder::new(tar_gz);
|
||||
let mut archive = Archive::new(gz_decoder);
|
||||
@ -207,3 +210,46 @@ pub fn setup_mcp(app: &App) {
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn setup_tray(app: &App) -> tauri::Result<TrayIcon> {
|
||||
let show_i = MenuItem::with_id(app.handle(), "open", "Open Jan", true, None::<&str>)?;
|
||||
let quit_i = MenuItem::with_id(app.handle(), "quit", "Quit", true, None::<&str>)?;
|
||||
let separator_i = PredefinedMenuItem::separator(app.handle())?;
|
||||
let menu = Menu::with_items(app.handle(), &[&show_i, &separator_i, &quit_i])?;
|
||||
TrayIconBuilder::with_id("tray")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_tray_icon_event(|tray, event| match event {
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} => {
|
||||
// let's show and focus the main window when the tray is clicked
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::debug!("unhandled event {event:?}");
|
||||
}
|
||||
})
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => {
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
other => {
|
||||
println!("menu item {} not handled", other);
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
}
|
||||
|
||||
@ -8,10 +8,12 @@ use core::{
|
||||
};
|
||||
use jan_utils::generate_app_token;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tauri::{Emitter, Manager, RunEvent};
|
||||
use tauri::{Manager, RunEvent};
|
||||
use tauri_plugin_llamacpp::cleanup_llama_processes;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::core::setup::setup_tray;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut builder = tauri::Builder::default();
|
||||
@ -108,6 +110,21 @@ pub fn run() {
|
||||
server_handle: Arc::new(Mutex::new(None)),
|
||||
tool_call_cancellations: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
if option_env!("ENABLE_SYSTEM_TRAY_ICON").unwrap_or("false") == "true" {
|
||||
#[cfg(target_os = "macos")]
|
||||
window
|
||||
.app_handle()
|
||||
.set_activation_policy(tauri::ActivationPolicy::Accessory)
|
||||
.unwrap();
|
||||
|
||||
window.hide().unwrap();
|
||||
api.prevent_close();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.setup(|app| {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
@ -129,6 +146,11 @@ pub fn run() {
|
||||
log::error!("Failed to install extensions: {}", e);
|
||||
}
|
||||
|
||||
if option_env!("ENABLE_SYSTEM_TRAY_ICON").unwrap_or("false") == "true" {
|
||||
log::info!("Enabling system tray icon");
|
||||
let _ = setup_tray(app);
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
@ -146,14 +168,12 @@ pub fn run() {
|
||||
// This is called when the app is actually exiting (e.g., macOS dock quit)
|
||||
// We can't prevent this, so run cleanup quickly
|
||||
let app_handle = app.clone();
|
||||
// Hide window immediately
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
tokio::task::block_in_place(|| {
|
||||
tauri::async_runtime::block_on(async {
|
||||
// Hide window immediately
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
let _ = window.emit("kill-mcp-servers", ());
|
||||
}
|
||||
|
||||
// Quick cleanup with shorter timeout
|
||||
let state = app_handle.state::<AppState>();
|
||||
let _ = clean_up_mcp_servers(state).await;
|
||||
|
||||
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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,
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
21
website/.gitignore
vendored
@ -1,21 +0,0 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
@ -1,183 +0,0 @@
|
||||
# API Specification Synchronization
|
||||
|
||||
This document explains how the Jan Server API specification is kept in sync with the documentation.
|
||||
|
||||
## Overview
|
||||
|
||||
The Jan documentation automatically synchronizes with the Jan Server API specification to ensure the API reference is always up to date. This is managed through GitHub Actions workflows that can be triggered in multiple ways.
|
||||
|
||||
## Synchronization Methods
|
||||
|
||||
### 1. Automatic Daily Sync
|
||||
- **Schedule**: Runs daily at 2 AM UTC
|
||||
- **Branch**: `dev`
|
||||
- **Behavior**: Fetches the latest spec and commits changes if any
|
||||
- **Workflow**: `.github/workflows/update-cloud-api-spec.yml`
|
||||
|
||||
### 2. Manual Trigger via GitHub UI
|
||||
Navigate to Actions → "Update Cloud API Spec" → Run workflow
|
||||
|
||||
Options:
|
||||
- **Commit changes**: Whether to commit changes directly (default: true)
|
||||
- **Custom spec URL**: Override the default API spec URL
|
||||
- **Create PR**: Create a pull request instead of direct commit (default: false)
|
||||
|
||||
### 3. Webhook Trigger (For Jan Server Team)
|
||||
|
||||
Send a repository dispatch event to trigger an update:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-H "Authorization: token YOUR_GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/janhq/jan/dispatches \
|
||||
-d '{
|
||||
"event_type": "update-api-spec",
|
||||
"client_payload": {
|
||||
"spec_url": "https://api.jan.ai/api/swagger/doc.json"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. Local Development
|
||||
|
||||
For local development, the spec is updated conditionally:
|
||||
|
||||
```bash
|
||||
# Force update the cloud spec
|
||||
bun run generate:cloud-spec-force
|
||||
|
||||
# Normal update (checks if update is needed)
|
||||
bun run generate:cloud-spec
|
||||
|
||||
# Update both local and cloud specs
|
||||
bun run generate:specs
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following environment variables can be configured in GitHub Secrets:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `JAN_SERVER_SPEC_URL` | URL to fetch the OpenAPI spec | `https://api.jan.ai/api/swagger/doc.json` |
|
||||
| `JAN_SERVER_PROD_URL` | Production API base URL | `https://api.jan.ai/v1` |
|
||||
| `JAN_SERVER_STAGING_URL` | Staging API base URL | `https://staging-api.jan.ai/v1` |
|
||||
|
||||
### Build Behavior
|
||||
|
||||
| Context | Behavior |
|
||||
|---------|----------|
|
||||
| Pull Request | Uses existing spec (no update) |
|
||||
| Push to dev | Uses existing spec (no update) |
|
||||
| Scheduled run | Updates spec and commits changes |
|
||||
| Manual trigger | Updates based on input options |
|
||||
| Webhook | Updates and creates PR |
|
||||
| Local dev | Updates if spec is >24hrs old or missing |
|
||||
|
||||
## Workflow Integration
|
||||
|
||||
### For Jan Server Team
|
||||
|
||||
When deploying a new API version:
|
||||
|
||||
1. **Option A: Automatic PR**
|
||||
- Deploy your API changes
|
||||
- Trigger the webhook (see above)
|
||||
- Review and merge the created PR
|
||||
|
||||
2. **Option B: Manual Update**
|
||||
- Go to [Actions](https://github.com/janhq/jan/actions/workflows/update-cloud-api-spec.yml)
|
||||
- Click "Run workflow"
|
||||
- Select options:
|
||||
- Set "Create PR" to `true` for review
|
||||
- Or leave as `false` for direct commit
|
||||
|
||||
3. **Option C: Wait for Daily Sync**
|
||||
- Changes will be picked up automatically at 2 AM UTC
|
||||
|
||||
### For Documentation Team
|
||||
|
||||
The API spec updates are handled automatically. However, you can:
|
||||
|
||||
1. **Force an update**: Run the "Update Cloud API Spec" workflow manually
|
||||
2. **Test locally**: Use `bun run generate:cloud-spec-force`
|
||||
3. **Review changes**: Check PRs labeled with `api` and `automated`
|
||||
|
||||
## Fallback Mechanism
|
||||
|
||||
If the Jan Server API is unavailable:
|
||||
|
||||
1. The workflow will use the last known good spec
|
||||
2. Local builds will fall back to the local OpenAPI spec
|
||||
3. The build will continue without failing
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Check Update Status
|
||||
|
||||
1. Go to [Actions](https://github.com/janhq/jan/actions/workflows/update-cloud-api-spec.yml)
|
||||
2. Check the latest run status
|
||||
3. Review the workflow summary for details
|
||||
|
||||
### Notifications
|
||||
|
||||
To add Slack/Discord notifications:
|
||||
|
||||
1. Add webhook URL to GitHub Secrets
|
||||
2. Uncomment notification section in workflow
|
||||
3. Configure message format as needed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Spec Update Fails
|
||||
|
||||
1. Check if the API endpoint is accessible
|
||||
2. Verify the spec URL is correct
|
||||
3. Check GitHub Actions logs for errors
|
||||
4. Ensure proper permissions for the workflow
|
||||
|
||||
### Changes Not Appearing
|
||||
|
||||
1. Verify the workflow completed successfully
|
||||
2. Check if changes were committed to the correct branch
|
||||
3. Ensure the build is using the updated spec
|
||||
4. Clear CDN cache if using Cloudflare
|
||||
|
||||
### Manual Recovery
|
||||
|
||||
If automated updates fail:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/janhq/jan.git
|
||||
cd jan/website
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Force update the spec
|
||||
FORCE_UPDATE=true bun run generate:cloud-spec
|
||||
|
||||
# Commit and push
|
||||
git add public/openapi/cloud-openapi.json
|
||||
git commit -m "chore: manual update of API spec"
|
||||
git push
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Version Control**: Always review significant API changes before merging
|
||||
2. **Testing**: Test the updated spec locally before deploying
|
||||
3. **Communication**: Notify the docs team of breaking API changes
|
||||
4. **Monitoring**: Set up alerts for failed spec updates
|
||||
5. **Documentation**: Update this guide when changing the sync process
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Open an issue in the [Jan repository](https://github.com/janhq/jan/issues)
|
||||
- Contact the documentation team on Discord
|
||||
- Check the [workflow runs](https://github.com/janhq/jan/actions) for debugging
|
||||
@ -1,48 +0,0 @@
|
||||
# Jan's Website
|
||||
|
||||
This website is [built with Starlight](https://starlight.astro.build)
|
||||
|
||||
|
||||
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed
|
||||
as a route based on its file name.
|
||||
|
||||
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
|
||||
|
||||
Static assets, like favicons, can be placed in the `public/` directory.
|
||||
|
||||
If you want to add new pages, these can go in the `src/pages/` directory. Because of the topics plugin
|
||||
we are using ([starlight sidebar topics](https://starlight-sidebar-topics.netlify.app/docs/guides/excluded-pages/))
|
||||
you will need to exclude them from the sidebar by adding them to the exclude list in `astro.config.mjs`, e.g., `exclude: ['/example'],`.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `bun install` | Installs dependencies |
|
||||
| `bun dev` | Starts local dev server at `localhost:4321` |
|
||||
| `bun build` | Build your production site to `./dist/` |
|
||||
| `bun preview` | Preview your build locally, before deploying |
|
||||
| `bun astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `bun astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 📖 API Reference Commands
|
||||
|
||||
The website includes interactive API documentation. These commands help manage the OpenAPI specifications:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------------- | :-------------------------------------------------------- |
|
||||
| `bun run api:dev` | Start dev server with API reference at `/api` |
|
||||
| `bun run api:local` | Start dev server with local API docs at `/api-reference/local` |
|
||||
| `bun run api:cloud` | Start dev server with cloud API docs at `/api-reference/cloud` |
|
||||
| `bun run generate:local-spec` | Generate/fix the local OpenAPI specification |
|
||||
| `bun run generate:cloud-spec` | Generate the cloud OpenAPI specification from Jan Server |
|
||||
| `bun run generate:cloud-spec-force` | Force update cloud spec (ignores cache/conditions) |
|
||||
|
||||
**API Reference Pages:**
|
||||
- `/api` - Landing page with Local and Server API options
|
||||
- `/api-reference/local` - Local API (llama.cpp) documentation
|
||||
- `/api-reference/cloud` - Jan Server API (vLLM) documentation
|
||||
|
||||
The cloud specification is automatically synced via GitHub Actions on a daily schedule and can be manually triggered by the Jan Server team.
|
||||
@ -1,306 +0,0 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config'
|
||||
import starlight from '@astrojs/starlight'
|
||||
import starlightThemeRapide from 'starlight-theme-rapide'
|
||||
import starlightSidebarTopics from 'starlight-sidebar-topics'
|
||||
import starlightUtils from '@lorenzo_lewis/starlight-utils'
|
||||
import react from '@astrojs/react'
|
||||
|
||||
import mermaid from 'astro-mermaid'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path, { dirname } from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
// Deploy to the new v2 subdomain
|
||||
site: 'https://docs.jan.ai',
|
||||
integrations: [
|
||||
react(),
|
||||
mermaid({
|
||||
theme: 'default',
|
||||
autoTheme: true,
|
||||
}),
|
||||
starlight({
|
||||
title: '👋 Jan',
|
||||
favicon: 'favicon.ico',
|
||||
customCss: ['./src/styles/global.css'],
|
||||
head: [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { src: '/scripts/inject-navigation.js', defer: true },
|
||||
},
|
||||
{
|
||||
tag: 'link',
|
||||
attrs: { rel: 'stylesheet', href: '/styles/navigation.css' },
|
||||
},
|
||||
],
|
||||
|
||||
plugins: [
|
||||
starlightThemeRapide(),
|
||||
starlightSidebarTopics(
|
||||
[
|
||||
{
|
||||
label: 'Jan',
|
||||
link: '/',
|
||||
icon: 'rocket',
|
||||
items: [{ label: 'Ecosystem', slug: 'index' }],
|
||||
},
|
||||
{
|
||||
label: 'Jan Desktop',
|
||||
link: '/jan/quickstart',
|
||||
icon: 'rocket',
|
||||
items: [
|
||||
{
|
||||
label: '🚀 QUICK START',
|
||||
items: [
|
||||
{ label: 'Getting Started', slug: 'jan/quickstart' },
|
||||
{
|
||||
label: 'Install Jan',
|
||||
collapsed: false,
|
||||
autogenerate: { directory: 'jan/installation' },
|
||||
},
|
||||
{ label: 'AI Assistants', slug: 'jan/assistants' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '🤖 MODELS',
|
||||
items: [
|
||||
{ label: 'Overview', slug: 'jan/manage-models' },
|
||||
{
|
||||
label: 'Jan Models',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Jan v1',
|
||||
slug: 'jan/jan-models/jan-v1',
|
||||
},
|
||||
{
|
||||
label: 'Research Models',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Jan Nano 32k',
|
||||
slug: 'jan/jan-models/jan-nano-32',
|
||||
},
|
||||
{
|
||||
label: 'Jan Nano 128k',
|
||||
slug: 'jan/jan-models/jan-nano-128',
|
||||
},
|
||||
{
|
||||
label: 'Lucy',
|
||||
slug: 'jan/jan-models/lucy',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cloud Providers',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ label: 'OpenAI', slug: 'jan/remote-models/openai' },
|
||||
{
|
||||
label: 'Anthropic',
|
||||
slug: 'jan/remote-models/anthropic',
|
||||
},
|
||||
{ label: 'Gemini', slug: 'jan/remote-models/google' },
|
||||
{ label: 'Groq', slug: 'jan/remote-models/groq' },
|
||||
{
|
||||
label: 'Mistral',
|
||||
slug: 'jan/remote-models/mistralai',
|
||||
},
|
||||
{ label: 'Cohere', slug: 'jan/remote-models/cohere' },
|
||||
{
|
||||
label: 'OpenRouter',
|
||||
slug: 'jan/remote-models/openrouter',
|
||||
},
|
||||
{
|
||||
label: 'HuggingFace 🤗',
|
||||
slug: 'jan/remote-models/huggingface',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Custom Providers',
|
||||
slug: 'jan/custom-provider',
|
||||
},
|
||||
{
|
||||
label: 'Multi-Modal Models',
|
||||
slug: 'jan/multi-modal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '🔧 TOOLS & INTEGRATIONS',
|
||||
items: [
|
||||
{ label: 'What is MCP?', slug: 'jan/mcp' },
|
||||
{
|
||||
label: 'Examples & Tutorials',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Web & Search',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Browser Control',
|
||||
slug: 'jan/mcp-examples/browser/browserbase',
|
||||
},
|
||||
{
|
||||
label: 'Serper Search',
|
||||
slug: 'jan/mcp-examples/search/serper',
|
||||
},
|
||||
{
|
||||
label: 'Exa Search',
|
||||
slug: 'jan/mcp-examples/search/exa',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Data & Analysis',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Jupyter Notebooks',
|
||||
slug: 'jan/mcp-examples/data-analysis/jupyter',
|
||||
},
|
||||
{
|
||||
label: 'Code Sandbox (E2B)',
|
||||
slug: 'jan/mcp-examples/data-analysis/e2b',
|
||||
},
|
||||
{
|
||||
label: 'Deep Financial Research',
|
||||
slug: 'jan/mcp-examples/deepresearch/octagon',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Productivity',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Linear',
|
||||
slug: 'jan/mcp-examples/productivity/linear',
|
||||
},
|
||||
{
|
||||
label: 'Todoist',
|
||||
slug: 'jan/mcp-examples/productivity/todoist',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Creative',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Design with Canva',
|
||||
slug: 'jan/mcp-examples/design/canva',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '⚙️ DEVELOPER',
|
||||
items: [
|
||||
{
|
||||
label: 'Local API Server',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ label: 'Overview', slug: 'local-server' },
|
||||
{
|
||||
label: 'API Configuration',
|
||||
slug: 'local-server/api-server',
|
||||
},
|
||||
{
|
||||
label: 'Engine Settings',
|
||||
slug: 'local-server/llama-cpp',
|
||||
},
|
||||
{
|
||||
label: 'Server Settings',
|
||||
slug: 'local-server/settings',
|
||||
},
|
||||
{
|
||||
label: 'Integrations',
|
||||
collapsed: true,
|
||||
autogenerate: {
|
||||
directory: 'local-server/integrations',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Technical Details',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Model Parameters',
|
||||
slug: 'jan/explanation/model-parameters',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '📚 REFERENCE',
|
||||
items: [
|
||||
{ label: 'Settings', slug: 'jan/settings' },
|
||||
{ label: 'Data Folder', slug: 'jan/data-folder' },
|
||||
{ label: 'Troubleshooting', slug: 'jan/troubleshooting' },
|
||||
{ label: 'Privacy Policy', slug: 'jan/privacy' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Browser Extension',
|
||||
link: '/browser/',
|
||||
badge: { text: 'Alpha', variant: 'tip' },
|
||||
icon: 'puzzle',
|
||||
items: [{ label: 'Overview', slug: 'browser' }],
|
||||
},
|
||||
{
|
||||
label: 'Jan Mobile',
|
||||
link: '/mobile/',
|
||||
badge: { text: 'Soon', variant: 'caution' },
|
||||
icon: 'phone',
|
||||
items: [{ label: 'Overview', slug: 'mobile' }],
|
||||
},
|
||||
{
|
||||
label: 'Jan Server',
|
||||
link: '/server/',
|
||||
badge: { text: 'Soon', variant: 'caution' },
|
||||
icon: 'forward-slash',
|
||||
items: [{ label: 'Overview', slug: 'server' }],
|
||||
},
|
||||
],
|
||||
{
|
||||
exclude: ['/api-reference', '/api-reference/**/*'],
|
||||
}
|
||||
),
|
||||
],
|
||||
social: [
|
||||
{
|
||||
icon: 'github',
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/menloresearch/jan',
|
||||
},
|
||||
{
|
||||
icon: 'x.com',
|
||||
label: 'X',
|
||||
href: 'https://twitter.com/jandotai',
|
||||
},
|
||||
{
|
||||
icon: 'discord',
|
||||
label: 'Discord',
|
||||
href: 'https://discord.com/invite/FTk2MvZwJH',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
1724
website/bun.lock
@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"prebuild": "bun scripts/fix-local-spec-complete.js && bun scripts/conditional-cloud-spec.js",
|
||||
"generate:local-spec": "bun scripts/fix-local-spec-complete.js",
|
||||
"generate:cloud-spec": "bun scripts/generate-cloud-spec.js",
|
||||
"generate:cloud-spec-force": "FORCE_UPDATE=true bun scripts/generate-cloud-spec.js",
|
||||
"api:dev": "astro dev --open /api",
|
||||
"api:local": "astro dev --open /api-reference/local",
|
||||
"api:cloud": "astro dev --open /api-reference/cloud"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@astrojs/starlight": "^0.35.1",
|
||||
"@lorenzo_lewis/starlight-utils": "^0.3.2",
|
||||
"@scalar/api-reference-react": "^0.7.42",
|
||||
"@types/react": "^19.1.12",
|
||||
"astro": "^5.6.1",
|
||||
"astro-mermaid": "^1.0.4",
|
||||
"mermaid": "^11.9.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"sharp": "^0.34.3",
|
||||
"starlight-openapi": "^0.19.1",
|
||||
"starlight-sidebar-topics": "^0.6.0",
|
||||
"starlight-theme-rapide": "^0.5.1",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 488 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 477 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 395 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 7.4 MiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.7 MiB |