Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.PLAN*
.worktrees/

# Byte-compiled / optimized / DLL files
__pycache__/
Expand Down
122 changes: 96 additions & 26 deletions admin/hub_module/frontend/src/contexts/AuthContext.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import api from '../services/api';
import {
isDesktopMode,
desktopLogin,
desktopLogout,
desktopGetToken,
desktopGetCurrentUser,
} from '../services/desktopAdapter';

const AuthContext = createContext(null);

Expand All @@ -10,25 +17,57 @@ export function AuthProvider({ children }) {

// Check for existing session on mount
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
fetchCurrentUser();
} else {
setLoading(false);
}
const checkSession = async () => {
try {
if (isDesktopMode()) {
// Desktop: check token in keychain
const token = await desktopGetToken();
if (token) {
await fetchCurrentUser();
} else {
setLoading(false);
}
} else {
// Browser: check token in localStorage
const token = localStorage.getItem('token');
if (token) {
fetchCurrentUser();
} else {
setLoading(false);
}
}
} catch (err) {
console.error('Session check failed:', err);
setLoading(false);
}
};
checkSession();
}, []);

const fetchCurrentUser = async () => {
try {
const response = await api.get('/api/v1/auth/me');
if (response.data.success && response.data.user) {
setUser(response.data.user);
if (isDesktopMode()) {
// Desktop: use Tauri to fetch current user
const user = await desktopGetCurrentUser();
if (user) {
setUser(user);
} else {
// TODO: Clear token from keychain if /auth/me fails (M3)
}
} else {
localStorage.removeItem('token');
// Browser: use axios
const response = await api.get('/api/v1/auth/me');
if (response.data.success && response.data.user) {
setUser(response.data.user);
} else {
localStorage.removeItem('token');
}
}
} catch (err) {
console.error('Failed to fetch user:', err);
localStorage.removeItem('token');
if (!isDesktopMode()) {
localStorage.removeItem('token');
}
} finally {
setLoading(false);
}
Expand All @@ -49,17 +88,33 @@ export function AuthProvider({ children }) {
const login = useCallback(async (email, password) => {
try {
setError(null);
const response = await api.post('/api/v1/auth/login', { email, password });
if (response.data.success) {
localStorage.setItem('token', response.data.token);
setUser(response.data.user);
return response.data;
}
// Handle requires verification response (403 with requiresVerification)
if (response.data.requiresVerification) {
const error = new Error(response.data.message || 'Email verification required');
error.requiresVerification = true;
throw error;

if (isDesktopMode()) {
// Desktop: use Tauri login command
const response = await desktopLogin(email, password);
if (response.success) {
// Token is stored in keychain; fetch full user data
const user = await desktopGetCurrentUser();
if (user) {
setUser(user);
}
return { success: true, user: response.user };
}
throw new Error('Login failed');
} else {
// Browser: use axios
const response = await api.post('/api/v1/auth/login', { email, password });
if (response.data.success) {
localStorage.setItem('token', response.data.token);
setUser(response.data.user);
return response.data;
}
// Handle requires verification response (403 with requiresVerification)
if (response.data.requiresVerification) {
const error = new Error(response.data.message || 'Email verification required');
error.requiresVerification = true;
throw error;
}
}
} catch (err) {
// Check for verification required in error response
Expand Down Expand Up @@ -129,18 +184,33 @@ export function AuthProvider({ children }) {

const handleOAuthCallback = useCallback(async (token) => {
if (token) {
localStorage.setItem('token', token);
await fetchCurrentUser();
if (isDesktopMode()) {
// TODO: Desktop OAuth callback → store token in keychain + fetch user (M2)
console.warn('[AuthContext] OAuth callback in desktop mode not yet implemented');
// For now, redirect to login with error
window.location.href = '/login?error=oauth_not_implemented';
} else {
localStorage.setItem('token', token);
await fetchCurrentUser();
}
}
}, []);

const logout = useCallback(async () => {
try {
await api.post('/api/v1/auth/logout');
if (isDesktopMode()) {
// Desktop: use Tauri logout command (clears keychain)
await desktopLogout();
} else {
// Browser: use axios
await api.post('/api/v1/auth/logout');
}
} catch (err) {
console.error('Logout error:', err);
} finally {
localStorage.removeItem('token');
if (!isDesktopMode()) {
localStorage.removeItem('token');
}
setUser(null);
}
}, []);
Expand Down
40 changes: 37 additions & 3 deletions admin/hub_module/frontend/src/services/api.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios';
import { isDesktopMode, desktopRequest, desktopGetToken } from './desktopAdapter';

const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '',
Expand All @@ -10,7 +11,13 @@ const api = axios.create({

// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
async (config) => {
// Desktop mode: token injected server-side, no need to add here
if (isDesktopMode()) {
return config;
}

// Browser mode: add token from localStorage
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
Expand All @@ -20,26 +27,53 @@ api.interceptors.request.use(
(error) => Promise.reject(error)
);

// Custom adapter for desktop mode
const desktopAdapter = async (config) => {
if (!isDesktopMode()) {
throw new Error('desktopAdapter called outside desktop mode');
}
return desktopRequest(config);
};

// Set adapter based on mode
if (isDesktopMode()) {
api.defaults.adapter = desktopAdapter;
}

// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;

// Handle 401 errors (token expired)
// TODO: In desktop mode, 401 → token refresh should be handled by Rust proxy (M3)
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

try {
// Desktop mode: token refresh handled server-side; just retry
if (isDesktopMode()) {
return api(originalRequest);
}

// Browser mode: refresh via HTTP
const response = await api.post('/api/v1/auth/refresh');
if (response.data.success) {
localStorage.setItem('token', response.data.token);
originalRequest.headers.Authorization = `Bearer ${response.data.token}`;
return api(originalRequest);
}
} catch (refreshError) {
localStorage.removeItem('token');
window.location.href = '/login';
if (isDesktopMode()) {
// Desktop: redirect to login (token is invalid in keychain)
// TODO: Emit a logout event so AuthContext can clear state (M3)
window.location.href = '/login';
} else {
// Browser: clear localStorage and redirect
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(refreshError);
}
}
Expand Down
150 changes: 150 additions & 0 deletions admin/hub_module/frontend/src/services/desktopAdapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Desktop mode adapter — routes API calls through Tauri when in desktop context
* Detects Tauri at runtime and provides a transport abstraction for both browser and desktop paths
* Browser: axios + localStorage (unchanged)
* Desktop: Tauri invoke('api_request') + keychain (token never in JS)
*/

/**
* Get Tauri invoke function — uses global Tauri API available in webview
* The desktop app injects __TAURI__ when loading the webui
*/
function getInvoke() {
// Tauri v2 makes invoke available on window.__TAURI__
if (typeof window !== 'undefined' && window.__TAURI__?.invoke) {
return window.__TAURI__.invoke;
}

// Fallback: check internal API
if (typeof window !== 'undefined' && window.__TAURI_INTERNALS__?.commands?.invoke) {
return window.__TAURI_INTERNALS__.commands.invoke;
}

throw new Error('Tauri invoke not available — not running in Tauri desktop context');
}

/**
* Detect if running inside Tauri webview
* Check for __TAURI__ globals injected by Tauri v2
*/
export function isDesktopMode() {
if (typeof window === 'undefined') return false;
// Check for Tauri globals that indicate we're in the Tauri webview
return !!(
window.__TAURI__ ||
window.__TAURI_INTERNALS__ ||
window.__TAURI_METADATA__
);
}

/**
* Desktop transport adapter — replaces axios for desktop mode
* Calls Tauri's api_request command (Rust-proxied, token injected server-side)
*/
export async function desktopRequest(config) {
if (!isDesktopMode()) {
throw new Error('desktopRequest called outside desktop mode');
}

const invoke = getInvoke();
const method = config.method?.toUpperCase() || 'GET';
const path = config.url || '';
const body = config.data ? JSON.stringify(config.data) : null;

console.log(`[desktopAdapter] ${method} ${path}`);

try {
const response = await invoke('api_request', {
method,
path,
body
});

// Response: { status, body }
return {
data: response.body ? JSON.parse(response.body) : null,
status: response.status,
statusText: response.status < 400 ? 'OK' : 'Error',
headers: {},
config,
request: {}
};
} catch (err) {
console.error(`[desktopAdapter] Error: ${err}`);
const error = new Error(err);
error.response = {
status: 0,
data: { error: err },
statusText: 'Transport Error'
};
throw error;
}
}

/**
* Desktop auth adapter — Tauri commands for token storage + login/logout
* Token stored in OS keychain; login/logout via Tauri commands
*/
export async function desktopLogin(email, password) {
const invoke = getInvoke();

console.log('[desktopAdapter] login');
try {
const response = await invoke('login', { email, password });
// Rust returns { email, role, success }; token is in keychain
return {
success: response.success,
email: response.email,
role: response.role,
user: {
email: response.email,
roles: response.role ? [response.role] : ['user']
}
};
} catch (err) {
console.error(`[desktopAdapter] login error: ${err}`);
throw new Error(err);
}
}

export async function desktopLogout() {
const invoke = getInvoke();

console.log('[desktopAdapter] logout');
try {
await invoke('logout');
} catch (err) {
console.error(`[desktopAdapter] logout error: ${err}`);
// Non-fatal: proceed with logout even if keychain clear fails
}
}

export async function desktopGetToken() {
const invoke = getInvoke();

try {
return await invoke('get_token');
} catch (err) {
console.error(`[desktopAdapter] get_token error: ${err}`);
return null;
}
}

export async function desktopGetCurrentUser() {
const invoke = getInvoke();

console.log('[desktopAdapter] fetching current user');
try {
const response = await invoke('api_request', {
method: 'GET',
path: '/api/v1/auth/me',
body: null
});

const userData = response.body ? JSON.parse(response.body) : null;
return userData?.user || userData;
} catch (err) {
console.error(`[desktopAdapter] get current user error: ${err}`);
return null;
}
}
Loading