Apply critical OIDC and notification system fixes while preserving i18n
- Merge OIDC authentication fixes from 3877e90:
* Enhanced JWKS discovery mechanism with multiple backup URLs
* Better support for non-standard OIDC providers (Authentik, etc.)
* Improved error handling for "Failed to get user information"
- Migrate to unified Sonner toast notification system:
* Replace custom success/error state management
* Remove redundant alert state variables
* Consistent user feedback across all components
- Improve code quality and function naming conventions
- PRESERVE all existing i18n functionality and Chinese translations
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
|||||||
deleteUser
|
deleteUser
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
@@ -57,8 +58,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
scopes: 'openid email profile'
|
scopes: 'openid email profile'
|
||||||
});
|
});
|
||||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
|
||||||
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const [users, setUsers] = React.useState<Array<{
|
const [users, setUsers] = React.useState<Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,8 +68,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||||
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
|
||||||
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
@@ -121,13 +118,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOidcLoading(true);
|
setOidcLoading(true);
|
||||||
setOidcError(null);
|
|
||||||
setOidcSuccess(null);
|
|
||||||
|
|
||||||
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||||
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
|
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
setOidcError(`Missing required fields: ${missing.join(', ')}`);
|
toast.error(`Missing required fields: ${missing.join(', ')}`);
|
||||||
setOidcLoading(false);
|
setOidcLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,9 +130,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await updateOIDCConfig(oidcConfig);
|
await updateOIDCConfig(oidcConfig);
|
||||||
setOidcSuccess("OIDC configuration updated successfully!");
|
toast.success("OIDC configuration updated successfully!");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setOidcError(err?.response?.data?.error || t('interface.failedToUpdateOidcConfig'));
|
toast.error(err?.response?.data?.error || t('interface.failedToUpdateOidcConfig'));
|
||||||
} finally {
|
} finally {
|
||||||
setOidcLoading(false);
|
setOidcLoading(false);
|
||||||
}
|
}
|
||||||
@@ -147,42 +142,44 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
setOidcConfig(prev => ({...prev, [field]: value}));
|
setOidcConfig(prev => ({...prev, [field]: value}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeUserAdmin = async (e: React.FormEvent) => {
|
const handleMakeUserAdmin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newAdminUsername.trim()) return;
|
if (!newAdminUsername.trim()) return;
|
||||||
setMakeAdminLoading(true);
|
setMakeAdminLoading(true);
|
||||||
setMakeAdminError(null);
|
|
||||||
setMakeAdminSuccess(null);
|
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await makeUserAdmin(newAdminUsername.trim());
|
await makeUserAdmin(newAdminUsername.trim());
|
||||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
toast.success(`User ${newAdminUsername} is now an admin`);
|
||||||
setNewAdminUsername("");
|
setNewAdminUsername("");
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setMakeAdminError(err?.response?.data?.error || t('interface.failedToMakeUserAdmin'));
|
toast.error(err?.response?.data?.error || t('interface.failedToMakeUserAdmin'));
|
||||||
} finally {
|
} finally {
|
||||||
setMakeAdminLoading(false);
|
setMakeAdminLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAdminStatus = async (username: string) => {
|
const handleRemoveAdminStatus = async (username: string) => {
|
||||||
if (!confirm(`Remove admin status from ${username}?`)) return;
|
if (!confirm(`Remove admin status from ${username}?`)) return;
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await removeAdminStatus(username);
|
await removeAdminStatus(username);
|
||||||
|
toast.success(`Admin status removed from ${username}`);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || 'Failed to remove admin status');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteUser = async (username: string) => {
|
const handleDeleteUser = async (username: string) => {
|
||||||
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await deleteUser(username);
|
await deleteUser(username);
|
||||||
|
toast.success(`User ${username} deleted successfully`);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || 'Failed to delete user');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,12 +240,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
|
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
|
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
|
||||||
|
|
||||||
{oidcError && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
|
||||||
<AlertDescription>{oidcError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -315,12 +306,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
})}>{t('admin.reset')}</Button>
|
})}>{t('admin.reset')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{oidcSuccess && (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>{t('admin.success')}</AlertTitle>
|
|
||||||
<AlertDescription>{oidcSuccess}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -358,7 +343,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
||||||
<TableCell className="px-4">
|
<TableCell className="px-4">
|
||||||
<Button variant="ghost" size="sm"
|
<Button variant="ghost" size="sm"
|
||||||
onClick={() => deleteUser(user.username)}
|
onClick={() => handleDeleteUser(user.username)}
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
disabled={user.is_admin}>
|
disabled={user.is_admin}>
|
||||||
<Trash2 className="h-4 w-4"/>
|
<Trash2 className="h-4 w-4"/>
|
||||||
@@ -378,7 +363,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
<h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3>
|
<h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3>
|
||||||
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
||||||
<h4 className="font-medium">{t('admin.makeUserAdmin')}</h4>
|
<h4 className="font-medium">{t('admin.makeUserAdmin')}</h4>
|
||||||
<form onSubmit={makeUserAdmin} className="space-y-4">
|
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-admin-username">{t('admin.username')}</Label>
|
<Label htmlFor="new-admin-username">{t('admin.username')}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -389,18 +374,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
|
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{makeAdminError && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
|
||||||
<AlertDescription>{makeAdminError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{makeAdminSuccess && (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>{t('admin.success')}</AlertTitle>
|
|
||||||
<AlertDescription>{makeAdminSuccess}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -427,7 +400,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
||||||
<TableCell className="px-4">
|
<TableCell className="px-4">
|
||||||
<Button variant="ghost" size="sm"
|
<Button variant="ghost" size="sm"
|
||||||
onClick={() => removeAdminStatus(admin.username)}
|
onClick={() => handleRemoveAdminStatus(admin.username)}
|
||||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
||||||
<Shield className="h-4 w-4"/>
|
<Shield className="h-4 w-4"/>
|
||||||
{t('admin.removeAdminButton')}
|
{t('admin.removeAdminButton')}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import React, {useEffect, useRef, useState} from "react";
|
|||||||
import {Switch} from "@/components/ui/switch.tsx";
|
import {Switch} from "@/components/ui/switch.tsx";
|
||||||
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||||
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
|
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -255,8 +256,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
toast.success(editingHost ? t('hosts.hostUpdated') : t('hosts.hostAdded'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(t('errors.saveError'));
|
toast.error(t('errors.saveError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/co
|
|||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
|
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {toast} from "sonner";
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -78,8 +79,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
await deleteSSHHost(hostId);
|
await deleteSSHHost(hostId);
|
||||||
await fetchHosts();
|
await fetchHosts();
|
||||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
toast.success(t('hosts.hostDeleted'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(t('hosts.failedToDeleteHost'));
|
toast.error(t('hosts.failedToDeleteHost'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -116,16 +118,19 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
const result = await bulkImportSSHHosts(hostsArray);
|
const result = await bulkImportSSHHosts(hostsArray);
|
||||||
|
|
||||||
if (result.success > 0) {
|
if (result.success > 0) {
|
||||||
alert(t('hosts.importCompleted', { success: result.success, failed: result.failed }) + (result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''));
|
toast.success(t('hosts.importCompleted', { success: result.success, failed: result.failed }));
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
toast.error(`Errors: ${result.errors.join(', ')}`);
|
||||||
|
}
|
||||||
await fetchHosts();
|
await fetchHosts();
|
||||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
} else {
|
} else {
|
||||||
alert(`${t('hosts.importFailed')}: ${result.errors.join('\n')}`);
|
toast.error(`${t('hosts.importFailed')}: ${result.errors.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson');
|
const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson');
|
||||||
alert(`${t('hosts.importError')}: ${errorMessage}`);
|
toast.error(`${t('hosts.importError')}: ${errorMessage}`);
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {Input} from "@/components/ui/input.tsx";
|
|||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
interface PasswordResetProps {
|
interface PasswordResetProps {
|
||||||
userInfo: {
|
userInfo: {
|
||||||
@@ -36,8 +37,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
|||||||
const result = await initiatePasswordReset(userInfo.username);
|
const result = await initiatePasswordReset(userInfo.username);
|
||||||
setResetStep("verify");
|
setResetStep("verify");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
toast.success(t('auth.resetCodeSent'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset'));
|
const errorMessage = err?.response?.data?.error || err?.message || t('errors.failedPasswordReset');
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,8 +65,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
|||||||
setTempToken(response.tempToken);
|
setTempToken(response.tempToken);
|
||||||
setResetStep("newPassword");
|
setResetStep("newPassword");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
toast.success(t('auth.codeVerified'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error || t('errors.failedVerifyCode'));
|
const errorMessage = err?.response?.data?.error || t('errors.failedVerifyCode');
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
}
|
}
|
||||||
@@ -73,13 +80,17 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
|||||||
setResetLoading(true);
|
setResetLoading(true);
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setError(t('errors.passwordMismatch'));
|
const errorMessage = t('errors.passwordMismatch');
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
if (newPassword.length < 6) {
|
||||||
setError(t('errors.weakPassword'));
|
const errorMessage = t('errors.weakPassword');
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,8 +106,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
setResetSuccess(true);
|
setResetSuccess(true);
|
||||||
|
toast.success(t('auth.passwordResetSuccess'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error || t('errors.failedCompleteReset'));
|
const errorMessage = err?.response?.data?.error || t('errors.failedCompleteReset');
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user