feat: Squashed commit of fixing "none" authentication and adding a sessions system for mobile, electron, and web
This commit is contained in:
@@ -29,6 +29,10 @@ import {
|
||||
Lock,
|
||||
Download,
|
||||
Upload,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -111,6 +115,21 @@ export function AdminSettings({
|
||||
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
|
||||
const [importPassword, setImportPassword] = React.useState("");
|
||||
|
||||
const [sessions, setSessions] = React.useState<
|
||||
Array<{
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
jwtToken: string;
|
||||
}>
|
||||
>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = React.useState(false);
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
@@ -152,6 +171,7 @@ export function AdminSettings({
|
||||
}
|
||||
});
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -538,6 +558,168 @@ export function AdminSettings({
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/users/sessions`
|
||||
: isDev
|
||||
? `http://localhost:30001/users/sessions`
|
||||
: `/users/sessions`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("jwt")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSessions(data.sessions || []);
|
||||
} else {
|
||||
toast.error(t("admin.failedToFetchSessions"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchSessions"));
|
||||
}
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
// Check if this is the current session
|
||||
const currentJWT = getCookie("jwt");
|
||||
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
|
||||
const isCurrentSession = currentSession?.id === sessionId;
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeSession"),
|
||||
async () => {
|
||||
try {
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/users/sessions/${sessionId}`
|
||||
: isDev
|
||||
? `http://localhost:30001/users/sessions/${sessionId}`
|
||||
: `/users/sessions/${sessionId}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("jwt")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(t("admin.sessionRevokedSuccessfully"));
|
||||
|
||||
// If user revoked their own session, reload the page after a brief delay
|
||||
if (isCurrentSession) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
} else {
|
||||
toast.error(t("admin.failedToRevokeSession"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSession"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleRevokeAllUserSessions = async (userId: string) => {
|
||||
// Check if revoking sessions for current user
|
||||
const isCurrentUser = currentUser?.id === userId;
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeAllSessions"),
|
||||
async () => {
|
||||
try {
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/users/sessions/revoke-all`
|
||||
: isDev
|
||||
? `http://localhost:30001/users/sessions/revoke-all`
|
||||
: `/users/sessions/revoke-all`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getCookie("jwt")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
targetUserId: userId,
|
||||
exceptCurrent: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
toast.success(
|
||||
data.message || t("admin.sessionsRevokedSuccessfully"),
|
||||
);
|
||||
|
||||
// If revoking sessions for current user, reload the page after a brief delay
|
||||
if (isCurrentUser) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
} else {
|
||||
toast.error(t("admin.failedToRevokeSessions"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSessions"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
@@ -578,6 +760,10 @@ export function AdminSettings({
|
||||
<Users className="h-4 w-4" />
|
||||
{t("admin.users")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Sessions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("admin.adminManagement")}
|
||||
@@ -944,6 +1130,137 @@ export function AdminSettings({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Session Management</h3>
|
||||
<Button
|
||||
onClick={fetchSessions}
|
||||
disabled={sessionsLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{sessionsLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionsLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading sessions...
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No active sessions found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Device</TableHead>
|
||||
<TableHead className="px-4">User</TableHead>
|
||||
<TableHead className="px-4">Created</TableHead>
|
||||
<TableHead className="px-4">Last Active</TableHead>
|
||||
<TableHead className="px-4">Expires</TableHead>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => {
|
||||
const DeviceIcon =
|
||||
session.deviceType === "desktop"
|
||||
? Monitor
|
||||
: session.deviceType === "mobile"
|
||||
? Smartphone
|
||||
: Globe;
|
||||
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const lastActiveDate = new Date(session.lastActiveAt);
|
||||
const expiresDate = new Date(session.expiresAt);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={
|
||||
session.isRevoked ? "opacity-50" : undefined
|
||||
}
|
||||
>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
Revoked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeSession(session.id)
|
||||
}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{session.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(
|
||||
session.userId,
|
||||
)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title="Revoke all sessions for this user"
|
||||
>
|
||||
Revoke All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="admins" className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOTPDialog } from "@/ui/Desktop/Navigation/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/Desktop/Navigation/SSHAuthDialog.tsx";
|
||||
import {
|
||||
Upload,
|
||||
FolderPlus,
|
||||
@@ -100,6 +101,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
||||
const [authDialogReason, setAuthDialogReason] = useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
||||
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
||||
const [isClosing, setIsClosing] = useState<boolean>(false);
|
||||
@@ -327,6 +332,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setAuthDialogReason(result.reason || "no_keyboard");
|
||||
setShowAuthDialog(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
try {
|
||||
@@ -1315,6 +1327,80 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
async function handleAuthDialogSubmit(credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) {
|
||||
if (!currentHost) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setShowAuthDialog(false);
|
||||
|
||||
const sessionId = currentHost.id.toString();
|
||||
|
||||
const result = await connectSSH(sessionId, {
|
||||
hostId: currentHost.id,
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
password: credentials.password,
|
||||
sshKey: credentials.sshKey,
|
||||
keyPassword: credentials.keyPassword,
|
||||
authType: credentials.password ? "password" : "key",
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
});
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sessionId);
|
||||
setTotpPrompt(result.prompt || "Verification code:");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setAuthDialogReason(result.reason || "auth_failed");
|
||||
setShowAuthDialog(true);
|
||||
setIsLoading(false);
|
||||
toast.error(t("fileManager.authenticationFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sessionId, currentPath);
|
||||
const files = Array.isArray(response)
|
||||
? response
|
||||
: response?.files || [];
|
||||
setFiles(files);
|
||||
clearSelection();
|
||||
initialLoadDoneRef.current = true;
|
||||
toast.success(t("fileManager.connectedSuccessfully"));
|
||||
logFileManagerActivity();
|
||||
} catch (dirError: unknown) {
|
||||
console.error("Failed to load initial directory:", dirError);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("SSH connection with credentials failed:", error);
|
||||
setAuthDialogReason("auth_failed");
|
||||
setShowAuthDialog(true);
|
||||
toast.error(
|
||||
t("fileManager.failedToConnect") + ": " + (error.message || error),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthDialogCancel() {
|
||||
setShowAuthDialog(false);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function generateUniqueName(
|
||||
baseName: string,
|
||||
type: "file" | "directory",
|
||||
@@ -1890,6 +1976,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
/>
|
||||
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authDialogReason}
|
||||
onSubmit={handleAuthDialogSubmit}
|
||||
onCancel={handleAuthDialogCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
name: currentHost.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getSnippets,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ui/Desktop/Navigation/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/Desktop/Navigation/SSHAuthDialog.tsx";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
@@ -104,6 +105,12 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
|
||||
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
||||
const [authDialogReason, setAuthDialogReason] = useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
|
||||
useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isFittingRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -237,6 +244,38 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function handleAuthDialogSubmit(credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) {
|
||||
if (webSocketRef.current && terminal) {
|
||||
// Send reconnect message with credentials
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "reconnect_with_credentials",
|
||||
data: {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
hostConfig: {
|
||||
...hostConfig,
|
||||
password: credentials.password,
|
||||
key: credentials.sshKey,
|
||||
keyPassword: credentials.keyPassword,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
setShowAuthDialog(false);
|
||||
setIsConnecting(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthDialogCancel() {
|
||||
setShowAuthDialog(false);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function scheduleNotify(cols: number, rows: number) {
|
||||
if (!(cols > 0 && rows > 0)) return;
|
||||
pendingSizeRef.current = { cols, rows };
|
||||
@@ -635,6 +674,25 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "keyboard_interactive_available") {
|
||||
// Keyboard-interactive auth is available (e.g., Warpgate OIDC)
|
||||
// Show terminal immediately so user can see auth prompts
|
||||
setKeyboardInteractiveDetected(true);
|
||||
setIsConnecting(false);
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "auth_method_not_available") {
|
||||
// Server doesn't support keyboard-interactive for "none" auth
|
||||
// Show SSHAuthDialog for manual credential entry
|
||||
setAuthDialogReason("no_keyboard");
|
||||
setShowAuthDialog(true);
|
||||
setIsConnecting(false);
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("terminal.messageParseError"));
|
||||
@@ -1041,6 +1099,20 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authDialogReason}
|
||||
onSubmit={handleAuthDialogSubmit}
|
||||
onCancel={handleAuthDialogCancel}
|
||||
hostInfo={{
|
||||
ip: hostConfig.ip,
|
||||
port: hostConfig.port,
|
||||
username: hostConfig.username,
|
||||
name: hostConfig.name,
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
{isConnecting && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { Monitor } from "lucide-react";
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
logoutUser,
|
||||
} from "../../main-axios.ts";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/ui/Desktop/Authentication/ElectronServerConfig.tsx";
|
||||
import { ElectronLoginForm } from "@/ui/Desktop/Authentication/ElectronLoginForm.tsx";
|
||||
|
||||
interface AuthProps extends React.ComponentProps<"div"> {
|
||||
setLoggedIn: (loggedIn: boolean) => void;
|
||||
@@ -586,6 +588,43 @@ export function Auth({
|
||||
);
|
||||
}
|
||||
|
||||
// Show ElectronLoginForm when Electron has a configured server and user is not logged in
|
||||
if (isElectron() && currentServerUrl && !loggedIn && !authLoading) {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-screen flex items-center justify-center p-4"
|
||||
{...props}
|
||||
>
|
||||
<div className="w-full max-w-4xl h-[90vh]">
|
||||
<ElectronLoginForm
|
||||
serverUrl={currentServerUrl}
|
||||
onAuthSuccess={async () => {
|
||||
try {
|
||||
const meRes = await getUserInfo();
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
} catch (err) {
|
||||
toast.error(t("errors.failedUserInfo"));
|
||||
}
|
||||
}}
|
||||
onChangeServer={() => {
|
||||
setShowServerConfig(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dbHealthChecking && !dbConnectionFailed) {
|
||||
return (
|
||||
<div
|
||||
@@ -664,11 +703,33 @@ export function Auth({
|
||||
);
|
||||
}
|
||||
|
||||
// Detect if we're running in Electron's WebView/iframe
|
||||
const isInElectronWebView = () => {
|
||||
try {
|
||||
// Check if we're in an iframe AND the parent is Electron
|
||||
if (window.self !== window.top) {
|
||||
// We're in an iframe, likely Electron's ElectronLoginForm
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin iframe, can't access parent
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
||||
{...props}
|
||||
>
|
||||
{isInElectronWebView() && (
|
||||
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
|
||||
<Monitor className="h-4 w-4" />
|
||||
<AlertTitle>{t("auth.desktopApp")}</AlertTitle>
|
||||
<AlertDescription>{t("auth.loggingInToDesktopApp")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{totpRequired && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="mb-6 text-center">
|
||||
|
||||
335
src/ui/Desktop/Authentication/ElectronLoginForm.tsx
Normal file
335
src/ui/Desktop/Authentication/ElectronLoginForm.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { getCookie } from "@/ui/main-axios.ts";
|
||||
|
||||
interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
onAuthSuccess: () => void;
|
||||
onChangeServer: () => void;
|
||||
}
|
||||
|
||||
export function ElectronLoginForm({
|
||||
serverUrl,
|
||||
onAuthSuccess,
|
||||
onChangeServer,
|
||||
}: ElectronLoginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const hasAuthenticatedRef = useRef(false);
|
||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for messages from iframe
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
// Only accept messages from our configured server
|
||||
try {
|
||||
const serverOrigin = new URL(serverUrl).origin;
|
||||
if (event.origin !== serverOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data && typeof event.data === "object") {
|
||||
const data = event.data;
|
||||
|
||||
if (
|
||||
data.type === "AUTH_SUCCESS" &&
|
||||
data.token &&
|
||||
!hasAuthenticatedRef.current &&
|
||||
!isAuthenticating
|
||||
) {
|
||||
console.log(
|
||||
"[ElectronLoginForm] Received auth success from iframe",
|
||||
);
|
||||
hasAuthenticatedRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
// Save JWT to localStorage (Electron mode)
|
||||
localStorage.setItem("jwt", data.token);
|
||||
|
||||
// Verify it was saved
|
||||
const savedToken = localStorage.getItem("jwt");
|
||||
if (!savedToken) {
|
||||
throw new Error("Failed to save JWT to localStorage");
|
||||
}
|
||||
|
||||
console.log("[ElectronLoginForm] JWT saved successfully");
|
||||
|
||||
// Small delay to ensure everything is saved
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
onAuthSuccess();
|
||||
} catch (err) {
|
||||
console.error("[ElectronLoginForm] Error saving JWT:", err);
|
||||
setError(t("errors.authTokenSaveFailed"));
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ElectronLoginForm] Error processing message:", err);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Inject script into iframe when it loads
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoading(false);
|
||||
|
||||
// Update current URL when iframe loads
|
||||
try {
|
||||
if (iframe.contentWindow) {
|
||||
setCurrentUrl(iframe.contentWindow.location.href);
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin, can't access - use serverUrl
|
||||
setCurrentUrl(serverUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
// Inject JavaScript to detect JWT
|
||||
const injectedScript = `
|
||||
(function() {
|
||||
console.log('[Electron WebView] Script injected');
|
||||
|
||||
let hasNotified = false;
|
||||
|
||||
function postJWTToParent(token, source) {
|
||||
if (hasNotified) return;
|
||||
hasNotified = true;
|
||||
|
||||
console.log('[Electron WebView] Posting JWT to parent, source:', source);
|
||||
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'AUTH_SUCCESS',
|
||||
token: token,
|
||||
source: source,
|
||||
platform: 'desktop',
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
} catch (e) {
|
||||
console.error('[Electron WebView] Error posting message:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
try {
|
||||
const localToken = localStorage.getItem('jwt');
|
||||
if (localToken && localToken.length > 20) {
|
||||
postJWTToParent(localToken, 'localStorage');
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionToken = sessionStorage.getItem('jwt');
|
||||
if (sessionToken && sessionToken.length > 20) {
|
||||
postJWTToParent(sessionToken, 'sessionStorage');
|
||||
return true;
|
||||
}
|
||||
|
||||
const cookies = document.cookie;
|
||||
if (cookies && cookies.length > 0) {
|
||||
const cookieArray = cookies.split('; ');
|
||||
const tokenCookie = cookieArray.find(row => row.startsWith('jwt='));
|
||||
|
||||
if (tokenCookie) {
|
||||
const token = tokenCookie.split('=')[1];
|
||||
if (token && token.length > 20) {
|
||||
postJWTToParent(token, 'cookie');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Electron WebView] Error in checkAuth:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Intercept localStorage.setItem
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = function(key, value) {
|
||||
originalSetItem.apply(this, arguments);
|
||||
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
||||
setTimeout(() => checkAuth(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Intercept sessionStorage.setItem
|
||||
const originalSessionSetItem = sessionStorage.setItem;
|
||||
sessionStorage.setItem = function(key, value) {
|
||||
originalSessionSetItem.apply(this, arguments);
|
||||
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
||||
setTimeout(() => checkAuth(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for JWT
|
||||
const intervalId = setInterval(() => {
|
||||
if (hasNotified) {
|
||||
clearInterval(intervalId);
|
||||
return;
|
||||
}
|
||||
if (checkAuth()) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Stop after 5 minutes
|
||||
setTimeout(() => {
|
||||
clearInterval(intervalId);
|
||||
}, 300000);
|
||||
|
||||
// Initial check
|
||||
checkAuth();
|
||||
})();
|
||||
`;
|
||||
|
||||
// Try to inject the script
|
||||
try {
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: "INJECT_SCRIPT", script: injectedScript },
|
||||
"*",
|
||||
);
|
||||
|
||||
// Also try direct execution if same origin
|
||||
iframe.contentWindow.eval(injectedScript);
|
||||
}
|
||||
} catch (err) {
|
||||
// Cross-origin restrictions - this is expected for external servers
|
||||
console.warn(
|
||||
"[ElectronLoginForm] Cannot inject script due to cross-origin restrictions",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ElectronLoginForm] Error in handleLoad:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setLoading(false);
|
||||
setError(t("errors.failedToLoadServer"));
|
||||
};
|
||||
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
iframe.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
iframe.removeEventListener("error", handleError);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = serverUrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
onChangeServer();
|
||||
};
|
||||
|
||||
// Format URL for display (remove protocol)
|
||||
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen bg-dark-bg flex flex-col">
|
||||
{/* Navigation Bar */}
|
||||
<div className="flex items-center justify-between p-4 bg-dark-bg border-b border-dark-border">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={isAuthenticating}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-base font-medium">
|
||||
{t("serverConfig.changeServer")}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={loading || isAuthenticating}
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-dark-bg z-40"
|
||||
style={{ marginTop: "60px" }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">
|
||||
{t("auth.loadingServer")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticating && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-dark-bg/80 z-40"
|
||||
style={{ marginTop: "60px" }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">
|
||||
{t("auth.authenticating")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Iframe Container */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={serverUrl}
|
||||
className="w-full h-full border-0"
|
||||
title="Server Authentication"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation"
|
||||
allow="clipboard-read; clipboard-write; cross-origin-isolated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,9 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
testServerConnection,
|
||||
type ServerConfig,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { CheckCircle, XCircle, Server, Wifi } from "lucide-react";
|
||||
import { Server } from "lucide-react";
|
||||
|
||||
interface ServerConfigProps {
|
||||
onServerConfigured: (serverUrl: string) => void;
|
||||
@@ -26,11 +25,7 @@ export function ElectronServerConfig({
|
||||
const { t } = useTranslation();
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
"unknown" | "success" | "error"
|
||||
>("unknown");
|
||||
|
||||
useEffect(() => {
|
||||
loadServerConfig();
|
||||
@@ -41,68 +36,32 @@ export function ElectronServerConfig({
|
||||
const config = await getServerConfig();
|
||||
if (config?.serverUrl) {
|
||||
setServerUrl(config.serverUrl);
|
||||
setConnectionStatus("success");
|
||||
}
|
||||
} catch {
|
||||
// Ignore config loading errors
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t("serverConfig.enterServerUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
if (
|
||||
!normalizedUrl.startsWith("http://") &&
|
||||
!normalizedUrl.startsWith("https://")
|
||||
) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
}
|
||||
|
||||
const result = await testServerConnection(normalizedUrl);
|
||||
|
||||
if (result.success) {
|
||||
setConnectionStatus("success");
|
||||
} else {
|
||||
setConnectionStatus("error");
|
||||
setError(result.error || t("serverConfig.connectionFailed"));
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
setError(t("serverConfig.connectionError"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t("serverConfig.enterServerUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionStatus !== "success") {
|
||||
setError(t("serverConfig.testConnectionFirst"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
|
||||
// Ensure URL has http:// or https://
|
||||
if (
|
||||
!normalizedUrl.startsWith("http://") &&
|
||||
!normalizedUrl.startsWith("https://")
|
||||
) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
setError(t("serverConfig.mustIncludeProtocol"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
@@ -126,7 +85,6 @@ export function ElectronServerConfig({
|
||||
|
||||
const handleUrlChange = (value: string) => {
|
||||
setServerUrl(value);
|
||||
setConnectionStatus("unknown");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
@@ -144,52 +102,17 @@ export function ElectronServerConfig({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="http://localhost:30001 or https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className="flex-1 h-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !serverUrl.trim() || loading}
|
||||
className="w-10 h-10 p-0 flex items-center justify-center"
|
||||
>
|
||||
{testing ? (
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Wifi className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="http://localhost:30001 or https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className="w-full h-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{connectionStatus !== "unknown" && (
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{connectionStatus === "success" ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-green-600">
|
||||
{t("serverConfig.connected")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-red-600">
|
||||
{t("serverConfig.disconnected")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
@@ -213,7 +136,7 @@ export function ElectronServerConfig({
|
||||
type="button"
|
||||
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loading || testing || connectionStatus !== "success"}
|
||||
disabled={loading || !serverUrl.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TERMINAL_THEMES,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
} from "@/constants/terminal-themes";
|
||||
import { SSHAuthDialog } from "@/ui/Desktop/Navigation/SSHAuthDialog.tsx";
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
|
||||
@@ -25,14 +25,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@radix-ui/react-dropdown-menu";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { FolderCard } from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx";
|
||||
import { getSSHHosts } from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { deleteAccount } from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -87,11 +83,6 @@ export function LeftSidebar({
|
||||
}: SidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||
const [deletePassword, setDeletePassword] = React.useState("");
|
||||
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem("leftSidebarOpen");
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
@@ -300,30 +291,6 @@ export function LeftSidebar({
|
||||
return [...pinned, ...rest];
|
||||
}, []);
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
setDeleteError(null);
|
||||
|
||||
if (!deletePassword.trim()) {
|
||||
setDeleteError(t("leftSidebar.passwordRequired"));
|
||||
setDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAccount(deletePassword);
|
||||
|
||||
handleLogout();
|
||||
} catch (err: unknown) {
|
||||
setDeleteError(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data
|
||||
?.error || t("leftSidebar.failedToDeleteAccount"),
|
||||
);
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SidebarProvider open={isSidebarOpen}>
|
||||
@@ -444,14 +411,6 @@ export function LeftSidebar({
|
||||
>
|
||||
<span>{t("common.logout")}</span>
|
||||
</DropdownMenuItem>
|
||||
<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={() => setDeleteAccountOpen(true)}
|
||||
>
|
||||
<span className="text-red-400">
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
@@ -469,114 +428,6 @@ export function LeftSidebar({
|
||||
<ChevronRight size={10} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteAccountOpen && (
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] pointer-events-auto isolate"
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
willChange: "z-index",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[400px] h-full bg-dark-bg border-r-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[9999999]"
|
||||
style={{
|
||||
boxShadow: "4px 0 20px rgba(0, 0, 0, 0.5)",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-border">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title={t("leftSidebar.closeDeleteAccount")}
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
{t("leftSidebar.deleteAccountWarning")}
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.warning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("leftSidebar.deleteAccountWarningDetails")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{deleteError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{deleteError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDeleteAccount} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-password">
|
||||
{t("leftSidebar.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="delete-password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder={t("placeholders.confirmPassword")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={deleteLoading || !deletePassword.trim()}
|
||||
>
|
||||
{deleteLoading
|
||||
? t("leftSidebar.deleting")
|
||||
: t("leftSidebar.deleteAccount")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
{t("leftSidebar.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
281
src/ui/Desktop/Navigation/SSHAuthDialog.tsx
Normal file
281
src/ui/Desktop/Navigation/SSHAuthDialog.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
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 { PasswordInput } from "@/components/ui/password-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, AlertCircle, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
interface SSHAuthDialogProps {
|
||||
isOpen: boolean;
|
||||
reason: "no_keyboard" | "auth_failed" | "timeout";
|
||||
onSubmit: (credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
hostInfo: {
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
name?: string;
|
||||
};
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function SSHAuthDialog({
|
||||
isOpen,
|
||||
reason,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hostInfo,
|
||||
backgroundColor = "#1e1e1e",
|
||||
}: SSHAuthDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
||||
const [password, setPassword] = useState("");
|
||||
const [sshKey, setSshKey] = useState("");
|
||||
const [keyPassword, setKeyPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getReasonMessage = () => {
|
||||
switch (reason) {
|
||||
case "no_keyboard":
|
||||
return t("auth.sshNoKeyboardInteractive");
|
||||
case "auth_failed":
|
||||
return t("auth.sshAuthenticationFailed");
|
||||
case "timeout":
|
||||
return t("auth.sshAuthenticationTimeout");
|
||||
default:
|
||||
return t("auth.sshAuthenticationRequired");
|
||||
}
|
||||
};
|
||||
|
||||
const getReasonDescription = () => {
|
||||
switch (reason) {
|
||||
case "no_keyboard":
|
||||
return t("auth.sshNoKeyboardInteractiveDescription");
|
||||
case "auth_failed":
|
||||
return t("auth.sshAuthFailedDescription");
|
||||
case "timeout":
|
||||
return t("auth.sshTimeoutDescription");
|
||||
default:
|
||||
return t("auth.sshProvideCredentialsDescription");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
} = {};
|
||||
|
||||
if (authTab === "password") {
|
||||
if (password.trim()) {
|
||||
credentials.password = password;
|
||||
}
|
||||
} else {
|
||||
if (sshKey.trim()) {
|
||||
credentials.sshKey = sshKey;
|
||||
if (keyPassword.trim()) {
|
||||
credentials.keyPassword = keyPassword;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(credentials);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyFileUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const fileContent = await file.text();
|
||||
setSshKey(fileContent);
|
||||
} catch (error) {
|
||||
console.error("Failed to read SSH key file:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = () => {
|
||||
if (authTab === "password") {
|
||||
return password.trim() !== "";
|
||||
} else {
|
||||
return sshKey.trim() !== "";
|
||||
}
|
||||
};
|
||||
|
||||
const hostDisplay = hostInfo.name
|
||||
? `${hostInfo.name} (${hostInfo.username}@${hostInfo.ip}:${hostInfo.port})`
|
||||
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
style={{ backgroundColor: `${backgroundColor}dd` }}
|
||||
>
|
||||
<Card className="w-full max-w-2xl mx-4 shadow-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t("auth.sshAuthenticationRequired")}
|
||||
</CardTitle>
|
||||
<CardDescription>{hostDisplay}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant={reason === "auth_failed" ? "destructive" : "default"}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{getReasonMessage()}</AlertTitle>
|
||||
<AlertDescription>{getReasonDescription()}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(v) => setAuthTab(v as "password" | "key")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="password">
|
||||
{t("credentials.password")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key">{t("credentials.sshKey")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="password" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssh-password">
|
||||
{t("credentials.password")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ssh-password"
|
||||
placeholder={t("placeholders.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("auth.sshPasswordDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="key" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssh-key">
|
||||
{t("credentials.sshPrivateKey")}
|
||||
</Label>
|
||||
<div className="mb-2">
|
||||
<div className="relative inline-block w-full">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept="*,.pem,.key,.txt,.ppk"
|
||||
onChange={handleKeyFileUpload}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
<span className="truncate">
|
||||
{t("credentials.uploadPrivateKeyFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CodeMirror
|
||||
value={sshKey}
|
||||
onChange={(value) => setSshKey(value)}
|
||||
placeholder={t("placeholders.pastePrivateKey")}
|
||||
theme={oneDark}
|
||||
className="border border-input rounded-md"
|
||||
minHeight="200px"
|
||||
maxHeight="300px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
highlightSelectionMatches: false,
|
||||
searchKeymap: false,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
extensions={[
|
||||
EditorView.theme({
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssh-key-password">
|
||||
{t("credentials.keyPassword")} ({t("common.optional")})
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ssh-key-password"
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
value={keyPassword}
|
||||
onChange={(e) => setKeyPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("auth.sshKeyPasswordDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit() || loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? t("common.connecting") : t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
@@ -10,8 +12,13 @@ import {
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { User, Shield, AlertCircle } from "lucide-react";
|
||||
import { TOTPSetup } from "@/ui/Desktop/User/TOTPSetup.tsx";
|
||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||
import { getVersionInfo } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
getUserInfo,
|
||||
getVersionInfo,
|
||||
deleteAccount,
|
||||
logoutUser,
|
||||
isElectron,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { PasswordReset } from "@/ui/Desktop/User/PasswordReset.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
|
||||
@@ -21,6 +28,21 @@ interface UserProfileProps {
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logoutUser();
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
@@ -36,6 +58,11 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
|
||||
null,
|
||||
);
|
||||
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
|
||||
const [deletePassword, setDeletePassword] = useState("");
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
fetchVersion();
|
||||
@@ -76,6 +103,29 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
setDeleteError(null);
|
||||
|
||||
if (!deletePassword.trim()) {
|
||||
setDeleteError(t("leftSidebar.passwordRequired"));
|
||||
setDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAccount(deletePassword);
|
||||
handleLogout();
|
||||
} catch (err: unknown) {
|
||||
setDeleteError(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data
|
||||
?.error || t("leftSidebar.failedToDeleteAccount"),
|
||||
);
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
@@ -139,127 +189,259 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"
|
||||
>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("nav.userProfile")}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
<>
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"
|
||||
>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("nav.userProfile")}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="px-6 py-4 overflow-auto flex-1">
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||
<TabsTrigger
|
||||
value="profile"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
{t("nav.userProfile")}
|
||||
</TabsTrigger>
|
||||
{!userInfo.is_oidc && (
|
||||
<div className="px-6 py-4 overflow-auto flex-1">
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
value="profile"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("profile.security")}
|
||||
<User className="w-4 h-4" />
|
||||
{t("nav.userProfile")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
{!userInfo.is_oidc && (
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("profile.security")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.accountInfo")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.username}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">{t("profile.role")}</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.is_admin
|
||||
? t("interface.administrator")
|
||||
: t("interface.user")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.authMethod")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.is_oidc
|
||||
? t("profile.external")
|
||||
: t("profile.local")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.twoFactorAuth")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? (
|
||||
<span className="text-gray-400">
|
||||
{t("auth.lockedOidcAuth")}
|
||||
</span>
|
||||
) : userInfo.totp_enabled ? (
|
||||
<span className="text-green-400 flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("common.enabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">
|
||||
{t("common.disabled")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.version")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{versionInfo?.version || t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.accountInfo")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.language")}
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.selectPreferredLanguage")}
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.username}
|
||||
</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.role")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.is_admin
|
||||
? t("interface.administrator")
|
||||
: t("interface.user")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.authMethod")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.is_oidc
|
||||
? t("profile.external")
|
||||
: t("profile.local")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.twoFactorAuth")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? (
|
||||
<span className="text-gray-400">
|
||||
{t("auth.lockedOidcAuth")}
|
||||
</span>
|
||||
) : userInfo.totp_enabled ? (
|
||||
<span className="text-green-400 flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("common.enabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">
|
||||
{t("common.disabled")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.version")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{versionInfo?.version || t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.selectPreferredLanguage")}
|
||||
</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-red-400">
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t(
|
||||
"leftSidebar.deleteAccountWarningShort",
|
||||
"This action is not reversible and will permanently delete your account.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteAccountOpen(true)}
|
||||
>
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<TOTPSetup
|
||||
isEnabled={userInfo.totp_enabled}
|
||||
onStatusChange={handleTOTPStatusChange}
|
||||
/>
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<TOTPSetup
|
||||
isEnabled={userInfo.totp_enabled}
|
||||
onStatusChange={handleTOTPStatusChange}
|
||||
/>
|
||||
|
||||
{!userInfo.is_oidc && <PasswordReset userInfo={userInfo} />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{!userInfo.is_oidc && <PasswordReset userInfo={userInfo} />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{deleteAccountOpen && (
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] pointer-events-auto isolate"
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
willChange: "z-index",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[400px] h-full bg-dark-bg border-r-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[9999999]"
|
||||
style={{
|
||||
boxShadow: "4px 0 20px rgba(0, 0, 0, 0.5)",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-border">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title={t("leftSidebar.closeDeleteAccount")}
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
{t("leftSidebar.deleteAccountWarning")}
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.warning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("leftSidebar.deleteAccountWarningDetails")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{deleteError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{deleteError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDeleteAccount} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-password">
|
||||
{t("leftSidebar.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="delete-password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder={t("placeholders.confirmPassword")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={deleteLoading || !deletePassword.trim()}
|
||||
>
|
||||
{deleteLoading
|
||||
? t("leftSidebar.deleting")
|
||||
: t("leftSidebar.deleteAccount")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
{t("leftSidebar.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { Smartphone } from "lucide-react";
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -22,9 +23,51 @@ import {
|
||||
verifyTOTPLogin,
|
||||
logoutUser,
|
||||
isElectron,
|
||||
getCookie,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
|
||||
/**
|
||||
* Detect if we're running inside a React Native WebView
|
||||
*/
|
||||
function isReactNativeWebView(): boolean {
|
||||
return typeof window !== "undefined" && !!(window as any).ReactNativeWebView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post JWT token to React Native WebView for mobile app authentication
|
||||
*/
|
||||
function postJWTToWebView() {
|
||||
if (!isReactNativeWebView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get JWT from localStorage or cookies
|
||||
const jwt = getCookie("jwt") || localStorage.getItem("jwt");
|
||||
|
||||
if (!jwt) {
|
||||
console.warn("JWT not found when trying to post to WebView");
|
||||
return;
|
||||
}
|
||||
|
||||
// Post message to React Native
|
||||
(window as any).ReactNativeWebView.postMessage(
|
||||
JSON.stringify({
|
||||
type: "AUTH_SUCCESS",
|
||||
token: jwt,
|
||||
source: "explicit",
|
||||
platform: "mobile",
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
console.log("JWT posted to React Native WebView");
|
||||
} catch (error) {
|
||||
console.error("Failed to post JWT to WebView:", error);
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthProps extends React.ComponentProps<"div"> {
|
||||
setLoggedIn: (loggedIn: boolean) => void;
|
||||
setIsAdmin: (isAdmin: boolean) => void;
|
||||
@@ -231,6 +274,10 @@ export function Auth({
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
|
||||
// Post JWT to React Native WebView if running in mobile app
|
||||
postJWTToWebView();
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
if (tab === "signup") {
|
||||
setSignupConfirmPassword("");
|
||||
@@ -395,6 +442,9 @@ export function Auth({
|
||||
username: res.username || null,
|
||||
userId: res.userId || null,
|
||||
});
|
||||
|
||||
// Post JWT to React Native WebView if running in mobile app
|
||||
postJWTToWebView();
|
||||
}, 100);
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
@@ -482,6 +532,10 @@ export function Auth({
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
|
||||
// Post JWT to React Native WebView if running in mobile app
|
||||
postJWTToWebView();
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
@@ -535,6 +589,13 @@ export function Auth({
|
||||
className={`w-full max-w-md flex flex-col bg-dark-bg ${className || ""}`}
|
||||
{...props}
|
||||
>
|
||||
{isReactNativeWebView() && (
|
||||
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
<AlertTitle>{t("auth.mobileApp")}</AlertTitle>
|
||||
<AlertDescription>{t("auth.loggingInToMobileApp")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{dbError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
|
||||
Reference in New Issue
Block a user