Skip to content
Open
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
11 changes: 11 additions & 0 deletions ui-react/apps/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import Login from "./pages/Login";
import Setup from "./pages/Setup";
import ConfirmAccount from "./pages/ConfirmAccount";
import AppLayout from "./components/layout/AppLayout";

const MfaLogin = lazy(() => import("./pages/MfaLogin"));
const MfaRecover = lazy(() => import("./pages/MfaRecover"));
const MfaResetRequest = lazy(() => import("./pages/MfaResetRequest"));
const MfaResetVerify = lazy(() => import("./pages/MfaResetVerify"));
const MfaResetComplete = lazy(() => import("./pages/MfaResetComplete"));
import LoginLayout from "./components/layout/LoginLayout";
import ConnectivityGuard from "./components/common/ConnectivityGuard";
import ProtectedRoute from "./components/common/ProtectedRoute";
Expand Down Expand Up @@ -33,6 +39,11 @@ export default function App() {
<Route element={<LoginLayout />}>
<Route path="/login" element={<Login />} />
<Route path="/confirm-account" element={<ConfirmAccount />} />
<Route path="/mfa-login" element={<MfaLogin />} />
<Route path="/mfa-recover" element={<MfaRecover />} />
<Route path="/mfa-reset-request" element={<MfaResetRequest />} />
<Route path="/mfa-reset-verify" element={<MfaResetVerify />} />
<Route path="/reset-mfa" element={<MfaResetComplete />} />
<Route path="/setup" element={<Setup />} />
</Route>
<Route element={<ProtectedRoute />}>
Expand Down
62 changes: 62 additions & 0 deletions ui-react/apps/admin/src/api/__tests__/interceptors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ beforeEach(() => {
role: null,
name: null,
loading: false,
error: null,
mfaToken: null,
});

useConnectivityStore.getState().markUp();
Expand Down Expand Up @@ -250,4 +252,64 @@ describe("response interceptor", () => {
expect(useConnectivityStore.getState().apiReachable).toBe(true);
}
});

describe("MFA token handling", () => {
it("stores x-mfa-token from 401 response in authStore", async () => {
const adapter = vi.fn().mockRejectedValue({
response: {
status: 401,
headers: { "x-mfa-token": "mfa-temp-token-456" },
data: {},
},
config: { url: "/api/login" },
isAxiosError: true,
});
client.defaults.adapter = adapter;

await expect(client.get("/api/login")).rejects.toBeDefined();

const state = useAuthStore.getState();
expect(state.mfaToken).toBe("mfa-temp-token-456");
});

it("logs out and redirects on 401 without x-mfa-token", async () => {
useAuthStore.setState({ token: "valid-token", user: "admin" });

const adapter = vi.fn().mockRejectedValue({
response: {
status: 401,
headers: {},
data: {},
},
isAxiosError: true,
});
client.defaults.adapter = adapter;

await expect(client.get("/test")).rejects.toBeDefined();

expect(useAuthStore.getState().token).toBeNull(); // Logged out
});

it("does not interfere with other status codes", async () => {
const validToken = makeJwt(futureExp());
useAuthStore.setState({ token: validToken });

const adapter = vi.fn().mockRejectedValue({
response: {
status: 403,
headers: { "x-mfa-token": "should-be-ignored" },
data: {},
},
isAxiosError: true,
});
client.defaults.adapter = adapter;

await expect(client.get("/test")).rejects.toBeDefined();

// MFA token should NOT be set for non-401 responses
expect(useAuthStore.getState().mfaToken).toBeNull();
// Should still be logged in
expect(useAuthStore.getState().token).toBe(validToken);
});
});
});
1 change: 1 addition & 0 deletions ui-react/apps/admin/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface UserResponse {
email: string;
recovery_email: string;
tenant: string;
mfa?: boolean;
}

export async function login(payload: LoginPayload): Promise<LoginResponse> {
Expand Down
18 changes: 14 additions & 4 deletions ui-react/apps/admin/src/api/interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,20 @@ export function setupInterceptors(instance: AxiosInstance) {
return response;
},
(error: AxiosError) => {
const isLoginRequest = error.config?.url?.includes("/api/login");
if (error.response?.status === 401 && !isLoginRequest) {
useAuthStore.getState().logout();
window.location.href = "/login";
if (error.response?.status === 401) {
// Check for MFA token first (applies to any URL)
const mfaToken = error.response.headers["x-mfa-token"];
if (mfaToken) {
useAuthStore.getState().setMfaToken(mfaToken);
return Promise.reject(error);
}

// No MFA token — only logout for non-login endpoints
const isLoginRequest = error.config?.url?.includes("/api/login");
if (!isLoginRequest) {
useAuthStore.getState().logout();
window.location.href = "/login";
}
} else if (isApiDown(error)) {
scheduleMarkDown();
}
Expand Down
72 changes: 72 additions & 0 deletions ui-react/apps/admin/src/api/mfa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import apiClient from "./client";
import type {
MfaGenerateResponse,
MfaEnableRequest,
MfaAuthRequest,
MfaDisableRequest,
MfaRecoverRequest,
MfaResetRequest,
LoginResponse,
} from "../types/mfa";

// Generate QR code and recovery codes
export async function generateMfa(): Promise<MfaGenerateResponse> {
const { data } = await apiClient.get<MfaGenerateResponse>(
"/api/user/mfa/generate"
);
return data;
}

// Enable MFA with verification
export async function enableMfa(payload: MfaEnableRequest): Promise<void> {
await apiClient.put("/api/user/mfa/enable", payload);
}

// Validate MFA code after password login
export async function validateMfa(
payload: MfaAuthRequest
): Promise<LoginResponse> {
const { data } = await apiClient.post<LoginResponse>(
"/api/user/mfa/auth",
payload
);
return data;
}

// Disable MFA
export async function disableMfa(payload: MfaDisableRequest): Promise<void> {
await apiClient.put("/api/user/mfa/disable", payload);
}

// Recover account with recovery code
export async function recoverMfa(
payload: MfaRecoverRequest
): Promise<{ data: LoginResponse; expiresAt: string }> {
const response = await apiClient.post<LoginResponse>(
"/api/user/mfa/recover",
payload
);
return {
data: response.data,
expiresAt: (response.headers["x-expires-at"] as string | undefined) || "",
};
}

// Request MFA reset via email
export async function requestMfaReset(identifier: string): Promise<string> {
await apiClient.post("/api/user/mfa/reset", { identifier });
// User ID will come from the email link, not from this response
return identifier;
}

// Complete MFA reset with email codes
export async function completeMfaReset(
userId: string,
payload: MfaResetRequest
): Promise<LoginResponse> {
const { data } = await apiClient.put<LoginResponse>(
`/api/user/mfa/reset/${userId}`,
payload
);
return data;
}
32 changes: 32 additions & 0 deletions ui-react/apps/admin/src/components/common/AuthFooterLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { BookOpenIcon } from "@heroicons/react/24/outline";

export default function AuthFooterLinks() {
return (
<div
className="flex items-center justify-center gap-6 mt-10 animate-fade-in"
style={{ animationDelay: "800ms" }}
>
<a
href="https://docs.shellhub.io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary transition-colors"
>
<BookOpenIcon className="w-3.5 h-3.5" />
Documentation
</a>
<span className="w-px h-3 bg-border" />
<a
href="https://github.com/shellhub-io/shellhub"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary transition-colors"
>
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Community
</a>
</div>
);
}
Loading
Loading