Implementation of TOTP (Time-based One-Time Password) authentication

This commit is contained in:
Rodrigo Polo
2025-08-29 20:29:33 -06:00
parent 8058ffd217
commit 83c41751ea
11 changed files with 1418 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx"
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Admin/AdminSettings";
import { UserProfile } from "@/ui/UserProfile";
import { Toaster } from "@/components/ui/sonner";
import { getUserInfo } from "@/ui/main-axios.ts";
@@ -86,6 +87,7 @@ function AppContent() {
const showHome = currentTabData?.type === 'home';
const showSshManager = currentTabData?.type === 'ssh_manager';
const showAdmin = currentTabData?.type === 'admin';
const showProfile = currentTabData?.type === 'profile';
return (
<div>
@@ -187,6 +189,20 @@ function AppContent() {
<AdminSettings isTopbarOpen={isTopbarOpen} />
</div>
<div
className="h-screen w-full"
style={{
visibility: showProfile ? "visible" : "hidden",
pointerEvents: showProfile ? "auto" : "none",
height: showProfile ? "100vh" : 0,
width: showProfile ? "100%" : 0,
position: showProfile ? "static" : "absolute",
overflow: "hidden",
}}
>
<UserProfile isTopbarOpen={isTopbarOpen} />
</div>
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar>
)}

View File

@@ -411,6 +411,11 @@ const migrateSchema = () => {
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
addColumnIfNotExists('users', 'name_path', 'TEXT');
addColumnIfNotExists('users', 'scopes', 'TEXT');
// Add TOTP columns
addColumnIfNotExists('users', 'totp_secret', 'TEXT');
addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT');
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');

View File

@@ -17,6 +17,10 @@ export const users = sqliteTable('users', {
identifier_path: text('identifier_path'),
name_path: text('name_path'),
scopes: text().default("openid email profile"),
totp_secret: text('totp_secret'),
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false),
totp_backup_codes: text('totp_backup_codes'),
});
export const settings = sqliteTable('settings', {

View File

@@ -6,6 +6,8 @@ import chalk from 'chalk';
import bcrypt from 'bcryptjs';
import {nanoid} from 'nanoid';
import jwt from 'jsonwebtoken';
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
import type {Request, Response, NextFunction} from 'express';
async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise<any> {
@@ -206,6 +208,9 @@ router.post('/create', async (req, res) => {
identifier_path: '',
name_path: '',
scopes: 'openid email profile',
totp_secret: null,
totp_enabled: false,
totp_backup_codes: null,
});
logger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`);
@@ -546,6 +551,17 @@ router.post('/login', async (req, res) => {
expiresIn: '50d',
});
if (userRecord.totp_enabled) {
return res.json({
requires_totp: true,
temp_token: jwt.sign(
{userId: userRecord.id, pending_totp: true},
jwtSecret,
{expiresIn: '10m'}
)
});
}
return res.json({
token,
is_admin: !!userRecord.is_admin,
@@ -579,7 +595,8 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
userId: user[0].id,
username: user[0].username,
is_admin: !!user[0].is_admin,
is_oidc: !!user[0].is_oidc
is_oidc: !!user[0].is_oidc,
totp_enabled: !!user[0].totp_enabled
});
} catch (err) {
logger.error('Failed to get username', err);
@@ -929,6 +946,285 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => {
}
});
// Route: Verify TOTP during login
// POST /users/totp/verify-login
router.post('/totp/verify-login', async (req, res) => {
const {temp_token, totp_code} = req.body;
if (!temp_token || !totp_code) {
return res.status(400).json({error: 'Token and TOTP code are required'});
}
const jwtSecret = process.env.JWT_SECRET || 'secret';
try {
const decoded = jwt.verify(temp_token, jwtSecret) as any;
if (!decoded.pending_totp) {
return res.status(401).json({error: 'Invalid temporary token'});
}
const user = await db.select().from(users).where(eq(users.id, decoded.userId));
if (!user || user.length === 0) {
return res.status(404).json({error: 'User not found'});
}
const userRecord = user[0];
if (!userRecord.totp_enabled || !userRecord.totp_secret) {
return res.status(400).json({error: 'TOTP not enabled for this user'});
}
const verified = speakeasy.totp.verify({
secret: userRecord.totp_secret,
encoding: 'base32',
token: totp_code,
window: 2
});
if (!verified) {
const backupCodes = userRecord.totp_backup_codes ? JSON.parse(userRecord.totp_backup_codes) : [];
const backupIndex = backupCodes.indexOf(totp_code);
if (backupIndex === -1) {
return res.status(401).json({error: 'Invalid TOTP code'});
}
backupCodes.splice(backupIndex, 1);
await db.update(users)
.set({totp_backup_codes: JSON.stringify(backupCodes)})
.where(eq(users.id, userRecord.id));
}
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {
expiresIn: '50d',
});
return res.json({
token,
is_admin: !!userRecord.is_admin,
username: userRecord.username
});
} catch (err) {
logger.error('TOTP verification failed', err);
return res.status(500).json({error: 'TOTP verification failed'});
}
});
// Route: Setup TOTP
// POST /users/totp/setup
router.post('/totp/setup', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({error: 'User not found'});
}
const userRecord = user[0];
if (userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is already enabled'});
}
const secret = speakeasy.generateSecret({
name: `Termix (${userRecord.username})`,
length: 32
});
await db.update(users)
.set({totp_secret: secret.base32})
.where(eq(users.id, userId));
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || '');
res.json({
secret: secret.base32,
qr_code: qrCodeUrl
});
} catch (err) {
logger.error('Failed to setup TOTP', err);
res.status(500).json({error: 'Failed to setup TOTP'});
}
});
// Route: Enable TOTP
// POST /users/totp/enable
router.post('/totp/enable', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const {totp_code} = req.body;
if (!totp_code) {
return res.status(400).json({error: 'TOTP code is required'});
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({error: 'User not found'});
}
const userRecord = user[0];
if (userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is already enabled'});
}
if (!userRecord.totp_secret) {
return res.status(400).json({error: 'TOTP setup not initiated'});
}
const verified = speakeasy.totp.verify({
secret: userRecord.totp_secret,
encoding: 'base32',
token: totp_code,
window: 2
});
if (!verified) {
return res.status(401).json({error: 'Invalid TOTP code'});
}
const backupCodes = Array.from({length: 8}, () =>
Math.random().toString(36).substring(2, 10).toUpperCase()
);
await db.update(users)
.set({
totp_enabled: true,
totp_backup_codes: JSON.stringify(backupCodes)
})
.where(eq(users.id, userId));
res.json({
message: 'TOTP enabled successfully',
backup_codes: backupCodes
});
} catch (err) {
logger.error('Failed to enable TOTP', err);
res.status(500).json({error: 'Failed to enable TOTP'});
}
});
// Route: Disable TOTP
// POST /users/totp/disable
router.post('/totp/disable', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const {password, totp_code} = req.body;
if (!password && !totp_code) {
return res.status(400).json({error: 'Password or TOTP code is required'});
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({error: 'User not found'});
}
const userRecord = user[0];
if (!userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is not enabled'});
}
if (password && !userRecord.is_oidc) {
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) {
return res.status(401).json({error: 'Incorrect password'});
}
} else if (totp_code) {
const verified = speakeasy.totp.verify({
secret: userRecord.totp_secret!,
encoding: 'base32',
token: totp_code,
window: 2
});
if (!verified) {
return res.status(401).json({error: 'Invalid TOTP code'});
}
} else {
return res.status(400).json({error: 'Authentication required'});
}
await db.update(users)
.set({
totp_enabled: false,
totp_secret: null,
totp_backup_codes: null
})
.where(eq(users.id, userId));
res.json({message: 'TOTP disabled successfully'});
} catch (err) {
logger.error('Failed to disable TOTP', err);
res.status(500).json({error: 'Failed to disable TOTP'});
}
});
// Route: Generate new backup codes
// POST /users/totp/backup-codes
router.post('/totp/backup-codes', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const {password, totp_code} = req.body;
if (!password && !totp_code) {
return res.status(400).json({error: 'Password or TOTP code is required'});
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({error: 'User not found'});
}
const userRecord = user[0];
if (!userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is not enabled'});
}
if (password && !userRecord.is_oidc) {
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) {
return res.status(401).json({error: 'Incorrect password'});
}
} else if (totp_code) {
const verified = speakeasy.totp.verify({
secret: userRecord.totp_secret!,
encoding: 'base32',
token: totp_code,
window: 2
});
if (!verified) {
return res.status(401).json({error: 'Invalid TOTP code'});
}
} else {
return res.status(400).json({error: 'Authentication required'});
}
const backupCodes = Array.from({length: 8}, () =>
Math.random().toString(36).substring(2, 10).toUpperCase()
);
await db.update(users)
.set({totp_backup_codes: JSON.stringify(backupCodes)})
.where(eq(users.id, userId));
res.json({backup_codes: backupCodes});
} catch (err) {
logger.error('Failed to generate backup codes', err);
res.status(500).json({error: 'Failed to generate backup codes'});
}
});
// Route: Delete user (admin only)
// DELETE /users/delete-user
router.delete('/delete-user', authenticateJWT, async (req, res) => {

View File

@@ -14,7 +14,8 @@ import {
initiatePasswordReset,
verifyPasswordResetCode,
completePasswordReset,
getOIDCAuthorizeUrl
getOIDCAuthorizeUrl,
verifyTOTPLogin
} from "../main-axios.ts";
function setCookie(name: string, value: string, days = 7) {
@@ -75,6 +76,11 @@ export function HomepageAuth({
const [tempToken, setTempToken] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [resetSuccess, setResetSuccess] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [totpTempToken, setTotpTempToken] = useState("");
const [totpLoading, setTotpLoading] = useState(false);
useEffect(() => {
setInternalLoggedIn(loggedIn);
@@ -147,6 +153,13 @@ export function HomepageAuth({
res = await loginUser(localUsername, password);
}
if (res.requires_totp) {
setTotpRequired(true);
setTotpTempToken(res.temp_token);
setLoading(false);
return;
}
if (!res || !res.token) {
throw new Error('No token received from login');
}
@@ -171,6 +184,9 @@ export function HomepageAuth({
if (tab === "signup") {
setSignupConfirmPassword("");
}
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Unknown error");
setInternalLoggedIn(false);
@@ -269,6 +285,47 @@ export function HomepageAuth({
setError(null);
}
async function handleTOTPVerification() {
if (totpCode.length !== 6) {
setError("Please enter a 6-digit code");
return;
}
setError(null);
setTotpLoading(true);
try {
const res = await verifyTOTPLogin(totpTempToken, totpCode);
if (!res || !res.token) {
throw new Error('No token received from TOTP verification');
}
setCookie("jwt", res.token);
const meRes = await getUserInfo();
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null
});
setInternalLoggedIn(true);
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Invalid TOTP code");
} finally {
setTotpLoading(false);
}
}
async function handleOIDCLogin() {
setError(null);
setOidcLoading(true);
@@ -381,7 +438,65 @@ export function HomepageAuth({
</AlertDescription>
</Alert>
)}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
{totpRequired && (
<div className="flex flex-col gap-5">
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">Two-Factor Authentication</h2>
<p className="text-muted-foreground">Enter the 6-digit code from your authenticator app</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="totp-code">Authentication Code</Label>
<Input
id="totp-code"
type="text"
placeholder="000000"
maxLength={6}
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
disabled={totpLoading}
className="text-center text-2xl tracking-widest font-mono"
autoComplete="one-time-code"
/>
<p className="text-xs text-muted-foreground text-center">
Or enter a backup code if you don't have access to your authenticator
</p>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={totpLoading || totpCode.length < 6}
onClick={handleTOTPVerification}
>
{totpLoading ? Spinner : "Verify"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={totpLoading}
onClick={() => {
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
setError(null);
}}
>
Cancel
</Button>
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
)}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && (
<>
<div className="flex gap-2 mb-6">
<button

View File

@@ -497,6 +497,20 @@ export function LeftSidebar({
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
if (isSplitScreenActive) return;
const profileTab = tabList.find((t: any) => t.type === 'profile');
if (profileTab) {
setCurrentTab(profileTab.id);
return;
}
const id = addTab({type: 'profile', title: 'Profile'} as any);
setCurrentTab(id);
}}>
<span>Profile & Security</span>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"

437
src/ui/TOTPSetup.tsx Normal file
View File

@@ -0,0 +1,437 @@
import React, { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react";
import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios";
import { toast } from "sonner";
interface TOTPSetupProps {
isEnabled: boolean;
onStatusChange?: (enabled: boolean) => void;
}
export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) {
const [isEnabled, setIsEnabled] = useState(initialEnabled);
const [isSettingUp, setIsSettingUp] = useState(false);
const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init");
const [qrCode, setQrCode] = useState("");
const [secret, setSecret] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [disableCode, setDisableCode] = useState("");
const handleSetupStart = async () => {
setError(null);
setLoading(true);
try {
const response = await setupTOTP();
setQrCode(response.qr_code);
setSecret(response.secret);
setSetupStep("qr");
setIsSettingUp(true);
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to start TOTP setup");
} finally {
setLoading(false);
}
};
const handleVerifyCode = async () => {
if (verificationCode.length !== 6) {
setError("Please enter a 6-digit code");
return;
}
setError(null);
setLoading(true);
try {
const response = await enableTOTP(verificationCode);
setBackupCodes(response.backup_codes);
setSetupStep("backup");
toast.success("Two-factor authentication enabled successfully!");
} catch (err: any) {
setError(err?.response?.data?.error || "Invalid verification code");
} finally {
setLoading(false);
}
};
const handleDisable = async () => {
setError(null);
setLoading(true);
try {
await disableTOTP(password || undefined, disableCode || undefined);
setIsEnabled(false);
setIsSettingUp(false);
setSetupStep("init");
setPassword("");
setDisableCode("");
onStatusChange?.(false);
toast.success("Two-factor authentication disabled");
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to disable TOTP");
} finally {
setLoading(false);
}
};
const handleGenerateNewBackupCodes = async () => {
setError(null);
setLoading(true);
try {
const response = await generateBackupCodes(password || undefined, disableCode || undefined);
setBackupCodes(response.backup_codes);
toast.success("New backup codes generated");
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to generate backup codes");
} finally {
setLoading(false);
}
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
};
const downloadBackupCodes = () => {
const content = `Termix Two-Factor Authentication Backup Codes\n` +
`Generated: ${new Date().toISOString()}\n\n` +
`Keep these codes in a safe place. Each code can only be used once.\n\n` +
backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'termix-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
toast.success("Backup codes downloaded");
};
const handleComplete = () => {
setIsEnabled(true);
setIsSettingUp(false);
setSetupStep("init");
setVerificationCode("");
onStatusChange?.(true);
};
if (isEnabled && !isSettingUp) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Two-Factor Authentication
</CardTitle>
<CardDescription>
Your account is protected with two-factor authentication
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertTitle>Enabled</AlertTitle>
<AlertDescription>
Two-factor authentication is currently active on your account
</AlertDescription>
</Alert>
<Tabs defaultValue="disable" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="disable">Disable 2FA</TabsTrigger>
<TabsTrigger value="backup">Backup Codes</TabsTrigger>
</TabsList>
<TabsContent value="disable" className="space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
Disabling two-factor authentication will make your account less secure
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="disable-password">Password or TOTP Code</Label>
<Input
id="disable-password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-sm text-muted-foreground">Or</p>
<Input
id="disable-code"
type="text"
placeholder="6-digit TOTP code"
maxLength={6}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
/>
</div>
<Button
variant="destructive"
onClick={handleDisable}
disabled={loading || (!password && !disableCode)}
>
Disable Two-Factor Authentication
</Button>
</TabsContent>
<TabsContent value="backup" className="space-y-4">
<p className="text-sm text-muted-foreground">
Generate new backup codes if you've lost your existing ones
</p>
<div className="space-y-2">
<Label htmlFor="backup-password">Password or TOTP Code</Label>
<Input
id="backup-password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-sm text-muted-foreground">Or</p>
<Input
id="backup-code"
type="text"
placeholder="6-digit TOTP code"
maxLength={6}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
/>
</div>
<Button
onClick={handleGenerateNewBackupCodes}
disabled={loading || (!password && !disableCode)}
>
Generate New Backup Codes
</Button>
{backupCodes.length > 0 && (
<div className="space-y-2 mt-4">
<div className="flex justify-between items-center">
<Label>Your Backup Codes</Label>
<Button
size="sm"
variant="outline"
onClick={downloadBackupCodes}
>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
{backupCodes.map((code, i) => (
<div key={i}>{code}</div>
))}
</div>
</div>
)}
</TabsContent>
</Tabs>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}
if (setupStep === "qr") {
return (
<Card>
<CardHeader>
<CardTitle>Set Up Two-Factor Authentication</CardTitle>
<CardDescription>
Step 1: Scan the QR code with your authenticator app
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<img src={qrCode} alt="TOTP QR Code" className="w-64 h-64" />
</div>
<div className="space-y-2">
<Label>Manual Entry Code</Label>
<div className="flex gap-2">
<Input
value={secret}
readOnly
className="font-mono text-sm"
/>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(secret, "Secret key")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
If you can't scan the QR code, enter this code manually in your authenticator app
</p>
</div>
<Button onClick={() => setSetupStep("verify")} className="w-full">
Next: Verify Code
</Button>
</CardContent>
</Card>
);
}
if (setupStep === "verify") {
return (
<Card>
<CardHeader>
<CardTitle>Verify Your Authenticator</CardTitle>
<CardDescription>
Step 2: Enter the 6-digit code from your authenticator app
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="verify-code">Verification Code</Label>
<Input
id="verify-code"
type="text"
placeholder="000000"
maxLength={6}
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, ''))}
className="text-center text-2xl tracking-widest font-mono"
/>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setSetupStep("qr")}
disabled={loading}
>
Back
</Button>
<Button
onClick={handleVerifyCode}
disabled={loading || verificationCode.length !== 6}
className="flex-1"
>
{loading ? "Verifying..." : "Verify and Enable"}
</Button>
</div>
</CardContent>
</Card>
);
}
if (setupStep === "backup") {
return (
<Card>
<CardHeader>
<CardTitle>Save Your Backup Codes</CardTitle>
<CardDescription>
Step 3: Store these codes in a safe place
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Important</AlertTitle>
<AlertDescription>
Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device.
</AlertDescription>
</Alert>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label>Your Backup Codes</Label>
<Button
size="sm"
variant="outline"
onClick={downloadBackupCodes}
>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
{backupCodes.map((code, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-muted-foreground">{i + 1}.</span>
<span>{code}</span>
</div>
))}
</div>
</div>
<Button onClick={handleComplete} className="w-full">
Complete Setup
</Button>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Two-Factor Authentication
</CardTitle>
<CardDescription>
Add an extra layer of security to your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Not Enabled</AlertTitle>
<AlertDescription>
Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in.
</AlertDescription>
</Alert>
<Button onClick={handleSetupStart} disabled={loading} className="w-full">
{loading ? "Setting up..." : "Enable Two-Factor Authentication"}
</Button>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}

171
src/ui/UserProfile.tsx Normal file
View File

@@ -0,0 +1,171 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { User, Shield, Key, AlertCircle } from "lucide-react";
import { TOTPSetup } from "@/ui/TOTPSetup";
import { getUserInfo } from "@/ui/main-axios";
import { toast } from "sonner";
interface UserProfileProps {
isTopbarOpen?: boolean;
}
export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
const [userInfo, setUserInfo] = useState<{
username: string;
is_admin: boolean;
is_oidc: boolean;
totp_enabled: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUserInfo();
}, []);
const fetchUserInfo = async () => {
setLoading(true);
setError(null);
try {
const info = await getUserInfo();
setUserInfo({
username: info.username,
is_admin: info.is_admin,
is_oidc: info.is_oidc,
totp_enabled: info.totp_enabled || false
});
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to load user information");
} finally {
setLoading(false);
}
};
const handleTOTPStatusChange = (enabled: boolean) => {
if (userInfo) {
setUserInfo({ ...userInfo, totp_enabled: enabled });
}
};
if (loading) {
return (
<div className="container max-w-4xl mx-auto p-6">
<Card>
<CardContent className="p-12 text-center">
<div className="animate-pulse">Loading user profile...</div>
</CardContent>
</Card>
</div>
);
}
if (error || !userInfo) {
return (
<div className="container max-w-4xl mx-auto p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error || "Failed to load user profile"}</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="container max-w-4xl mx-auto p-6" style={{
marginTop: isTopbarOpen ? '60px' : '0',
transition: 'margin-top 0.3s ease'
}}>
<div className="mb-6">
<h1 className="text-3xl font-bold">User Profile</h1>
<p className="text-muted-foreground mt-2">Manage your account settings and security</p>
</div>
<Tabs defaultValue="profile" className="space-y-4">
<TabsList>
<TabsTrigger value="profile" className="flex items-center gap-2">
<User className="w-4 h-4" />
Profile
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
Security
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>Your account details and settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Username</Label>
<p className="text-lg font-medium mt-1">{userInfo.username}</p>
</div>
<div>
<Label>Account Type</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_admin ? "Administrator" : "User"}
</p>
</div>
<div>
<Label>Authentication Method</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_oidc ? "External (OIDC)" : "Local"}
</p>
</div>
<div>
<Label>Two-Factor Authentication</Label>
<p className="text-lg font-medium mt-1">
{userInfo.totp_enabled ? (
<span className="text-green-600 flex items-center gap-1">
<Shield className="w-4 h-4" />
Enabled
</span>
) : (
<span className="text-muted-foreground">Disabled</span>
)}
</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security" className="space-y-4">
<TOTPSetup
isEnabled={userInfo.totp_enabled}
onStatusChange={handleTOTPStatusChange}
/>
{!userInfo.is_oidc && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
Password
</CardTitle>
<CardDescription>
Change your account password
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Password change functionality can be implemented here
</p>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -892,6 +892,56 @@ export async function updateOIDCConfig(config: any): Promise<any> {
// ALERTS
// ============================================================================
export async function setupTOTP(): Promise<{ secret: string; qr_code: string }> {
try {
const response = await authApi.post('/users/totp/setup');
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
throw error;
}
}
export async function enableTOTP(totp_code: string): Promise<{ message: string; backup_codes: string[] }> {
try {
const response = await authApi.post('/users/totp/enable', { totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
throw error;
}
}
export async function disableTOTP(password?: string, totp_code?: string): Promise<{ message: string }> {
try {
const response = await authApi.post('/users/totp/disable', { password, totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
throw error;
}
}
export async function verifyTOTPLogin(temp_token: string, totp_code: string): Promise<AuthResponse> {
try {
const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
throw error;
}
}
export async function generateBackupCodes(password?: string, totp_code?: string): Promise<{ backup_codes: string[] }> {
try {
const response = await authApi.post('/users/totp/backup-codes', { password, totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
throw error;
}
}
export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> {
try {
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');