Fix OIDC errors for "Failed to get user information"

This commit is contained in:
LukeGus
2025-09-02 16:55:08 -05:00
parent 915177cdb8
commit 79d17d2f70
4 changed files with 147 additions and 99 deletions

View File

@@ -222,6 +222,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => {
issuer_url,
authorization_url,
token_url,
userinfo_url,
identifier_path,
name_path,
scopes
@@ -240,6 +241,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => {
issuer_url,
authorization_url,
token_url,
userinfo_url: userinfo_url || '',
identifier_path,
name_path,
scopes: scopes || 'openid email profile'
@@ -362,14 +364,38 @@ router.get('/oidc/callback', async (req, res) => {
const tokenData = await tokenResponse.json() as any;
let userInfo: any = null;
const userInfoUrls = [];
let userInfoUrls: string[] = [];
const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url;
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
userInfoUrls.push(`${baseUrl}/userinfo/`);
userInfoUrls.push(`${normalizedIssuerUrl}/userinfo/`);
userInfoUrls.push(`${normalizedIssuerUrl}/userinfo`);
try {
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl);
if (discoveryResponse.ok) {
const discovery = await discoveryResponse.json() as any;
if (discovery.userinfo_endpoint) {
userInfoUrls.push(discovery.userinfo_endpoint);
}
}
} catch (discoveryError) {
logger.error(`OIDC discovery failed: ${discoveryError}`);
}
if (config.userinfo_url) {
userInfoUrls.unshift(config.userinfo_url);
}
userInfoUrls.push(
`${baseUrl}/userinfo/`,
`${baseUrl}/userinfo`,
`${normalizedIssuerUrl}/userinfo/`,
`${normalizedIssuerUrl}/userinfo`,
`${baseUrl}/oauth2/userinfo/`,
`${baseUrl}/oauth2/userinfo`,
`${normalizedIssuerUrl}/oauth2/userinfo/`,
`${normalizedIssuerUrl}/oauth2/userinfo`
);
if (tokenData.id_token) {
try {
@@ -391,15 +417,34 @@ router.get('/oidc/callback', async (req, res) => {
if (userInfoResponse.ok) {
userInfo = await userInfoResponse.json();
break;
} else {
logger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`);
}
} catch (error) {
logger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
continue;
}
}
}
if (!userInfo && tokenData.id_token) {
try {
const parts = tokenData.id_token.split('.');
if (parts.length === 3) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
userInfo = payload;
}
} catch (error) {
logger.error('Failed to decode ID token payload:', error);
}
}
if (!userInfo) {
logger.error('Failed to get user information from all sources');
logger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`);
logger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`);
logger.error(`Has id_token: ${!!tokenData.id_token}`);
logger.error(`Has access_token: ${!!tokenData.access_token}`);
return res.status(400).json({error: 'Failed to get user information'});
}

View File

@@ -26,7 +26,6 @@ import {
removeAdminStatus,
deleteUser
} from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next";
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
@@ -41,7 +40,6 @@ interface AdminSettingsProps {
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
const {t} = useTranslation();
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
@@ -137,7 +135,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
await updateOIDCConfig(oidcConfig);
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || t('interface.failedToUpdateOidcConfig'));
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally {
setOidcLoading(false);
}
@@ -160,7 +158,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || t('interface.failedToMakeUserAdmin'));
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
} finally {
setMakeAdminLoading(false);
}
@@ -202,7 +200,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] 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('admin.title')}</h1>
<h1 className="font-bold text-lg">Admin Settings</h1>
</div>
<Separator className="p-0.25 w-full"/>
@@ -211,98 +209,99 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
{t('common.settings')}
General
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
{t('admin.oidcSettings')}
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
{t('admin.users')}
Users
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
{t('nav.admin')}
Admins
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3>
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
disabled={regLoading}/>
{t('admin.allowRegistration')}
Allow new account registration
</label>
</div>
</TabsContent>
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">Configure external identity provider for
OIDC/OAuth2 authentication.</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">{t('admin.clientId')}</Label>
<Label htmlFor="client_id">Client ID</Label>
<Input id="client_id" value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder={t('placeholders.clientId')} required/>
placeholder="your-client-id" required/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">{t('admin.clientSecret')}</Label>
<Label htmlFor="client_secret">Client Secret</Label>
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder={t('placeholders.clientSecret')} required/>
placeholder="your-client-secret" required/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label>
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input id="authorization_url" value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder={t('placeholders.authUrl')}
placeholder="https://your-provider.com/application/o/authorize/"
required/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">{t('admin.issuerUrl')}</Label>
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder={t('placeholders.redirectUrl')} required/>
placeholder="https://your-provider.com/application/o/termix/" required/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">{t('admin.tokenUrl')}</Label>
<Label htmlFor="token_url">Token URL</Label>
<Input id="token_url" value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder={t('placeholders.tokenUrl')} required/>
placeholder="https://your-provider.com/application/o/token/" required/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">{t('admin.userIdentifierPath')}</Label>
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input id="identifier_path" value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder={t('placeholders.userIdField')} required/>
placeholder="sub" required/>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">{t('admin.displayNamePath')}</Label>
<Label htmlFor="name_path">Display Name Path</Label>
<Input id="name_path" value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder={t('placeholders.usernameField')} required/>
placeholder="name" required/>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">{t('admin.scopes')}</Label>
<Label htmlFor="scopes">Scopes</Label>
<Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder={t('placeholders.scopes')} required/>
placeholder="openid email profile" required/>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1"
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({
client_id: '',
client_secret: '',
@@ -312,12 +311,12 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
})}>{t('admin.reset')}</Button>
})}>Reset</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>{t('admin.success')}</AlertTitle>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
@@ -328,20 +327,20 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3>
<h3 className="text-lg font-semibold">User Management</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">{t('admin.username')}</TableHead>
<TableHead className="px-4">{t('admin.type')}</TableHead>
<TableHead className="px-4">{t('admin.actions')}</TableHead>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -351,11 +350,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
{user.username}
{user.is_admin && (
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
)}
</TableCell>
<TableCell
className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm"
onClick={() => deleteUser(user.username)}
@@ -375,29 +374,29 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3>
<h3 className="text-lg font-semibold">Admin Management</h3>
<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">Make User Admin</h4>
<form onSubmit={makeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">{t('admin.username')}</Label>
<Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder={t('placeholders.enterUsername')} required/>
placeholder="Enter username to make admin" required/>
<Button type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
{makeAdminSuccess && (
<Alert>
<AlertTitle>{t('admin.success')}</AlertTitle>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
@@ -405,14 +404,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
</div>
<div className="space-y-4">
<h4 className="font-medium">{t('admin.currentAdmins')}</h4>
<h4 className="font-medium">Current Admins</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">{t('admin.username')}</TableHead>
<TableHead className="px-4">{t('admin.type')}</TableHead>
<TableHead className="px-4">{t('admin.actions')}</TableHead>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -424,13 +423,13 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
</TableCell>
<TableCell
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/>
{t('admin.removeAdminButton')}
Remove Admin
</Button>
</TableCell>
</TableRow>

View File

@@ -1,5 +1,4 @@
import React, {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {
Computer,
Server,
@@ -113,7 +112,6 @@ export function LeftSidebar({
username,
children,
}: SidebarProps): React.ReactElement {
const {t} = useTranslation();
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
@@ -142,7 +140,7 @@ export function LeftSidebar({
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return;
const id = addTab({type: 'ssh_manager', title: t('hosts.title')} as any);
const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any);
setCurrentTab(id);
};
const adminTab = tabList.find((t) => t.type === 'admin');
@@ -152,7 +150,7 @@ export function LeftSidebar({
setCurrentTab(adminTab.id);
return;
}
const id = addTab({type: 'admin', title: t('nav.admin')} as any);
const id = addTab({type: 'admin', title: 'Admin'} as any);
setCurrentTab(id);
};
@@ -234,7 +232,7 @@ export function LeftSidebar({
}, 50);
}
} catch (err: any) {
setHostsError(t('leftSidebar.failedToLoadHosts'));
setHostsError('Failed to load hosts');
}
}, []);
@@ -277,7 +275,7 @@ export function LeftSidebar({
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => {
const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder');
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
if (!map[folder]) map[folder] = [];
map[folder].push(h);
});
@@ -287,8 +285,8 @@ export function LeftSidebar({
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === t('leftSidebar.noFolder')) return -1;
if (b === t('leftSidebar.noFolder')) return 1;
if (a === 'No Folder') return -1;
if (b === 'No Folder') return 1;
return a.localeCompare(b);
});
return folders;
@@ -306,7 +304,7 @@ export function LeftSidebar({
setDeleteError(null);
if (!deletePassword.trim()) {
setDeleteError(t('leftSidebar.passwordRequired'));
setDeleteError("Password is required");
setDeleteLoading(false);
return;
}
@@ -317,7 +315,7 @@ export function LeftSidebar({
handleLogout();
} catch (err: any) {
setDeleteError(err?.response?.data?.error || t('leftSidebar.failedToDeleteAccount'));
setDeleteError(err?.response?.data?.error || "Failed to delete account");
setDeleteLoading(false);
}
};
@@ -372,18 +370,18 @@ export function LeftSidebar({
const jwt = getCookie("jwt");
try {
await makeUserAdmin(newAdminUsername.trim());
setMakeAdminSuccess(t('leftSidebar.userIsNowAdmin', {username: newAdminUsername}));
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || t('leftSidebar.failedToMakeUserAdmin'));
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
} finally {
setMakeAdminLoading(false);
}
};
const removeAdminStatus = async (username: string) => {
if (!confirm(t('leftSidebar.removeAdminConfirm', {username}))) return;
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
if (!isAdmin) {
return;
@@ -398,7 +396,7 @@ export function LeftSidebar({
};
const deleteUser = async (username: string) => {
if (!confirm(t('leftSidebar.deleteUserConfirm', {username}))) return;
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
if (!isAdmin) {
return;
@@ -433,9 +431,9 @@ export function LeftSidebar({
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold border-2 !border-[#303032]" variant="outline"
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}>
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
<HardDrive strokeWidth="2.5"/>
{t('nav.hostManager')}
Host Manager
</Button>
</SidebarGroup>
<Separator className="p-0.25"/>
@@ -444,7 +442,7 @@ export function LeftSidebar({
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('placeholders.searchHostsAny')}
placeholder="Search hosts by any info..."
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
autoComplete="off"
/>
@@ -462,7 +460,7 @@ export function LeftSidebar({
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
{t('common.loading')}
Loading hosts...
</div>
</div>
)}
@@ -489,7 +487,7 @@ export function LeftSidebar({
style={{width: '100%'}}
disabled={disabled}
>
<User2/> {username ? username : t('common.logout')}
<User2/> {username ? username : 'Signed out'}
<ChevronUp className="ml-auto"/>
</SidebarMenuButton>
</DropdownMenuTrigger>
@@ -508,10 +506,10 @@ export function LeftSidebar({
setCurrentTab(profileTab.id);
return;
}
const id = addTab({type: 'profile', title: t('common.profile')} as any);
const id = addTab({type: 'profile', title: 'Profile'} as any);
setCurrentTab(id);
}}>
<span>{t('common.profile')}</span>
<span>Profile & Security</span>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
@@ -519,13 +517,13 @@ export function LeftSidebar({
onClick={() => {
if (isAdmin) openAdminTab();
}}>
<span>{t('admin.title')}</span>
<span>Admin Settings</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={handleLogout}>
<span>{t('common.logout')}</span>
<span>Sign out</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"
@@ -534,7 +532,7 @@ export function LeftSidebar({
>
<span
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}>
{t('admin.deleteUser')}
Delete Account
{isAdmin && adminCount <= 1 && " (Last Admin)"}
</span>
</DropdownMenuItem>
@@ -588,7 +586,7 @@ export function LeftSidebar({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
<h2 className="text-lg font-semibold text-white">{t('leftSidebar.deleteAccount')}</h2>
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
<Button
variant="outline"
size="sm"
@@ -598,7 +596,7 @@ export function LeftSidebar({
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')}
title="Close Delete Account"
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
@@ -607,19 +605,22 @@ export function LeftSidebar({
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div className="text-sm text-gray-300">
{t('leftSidebar.deleteAccountWarning')}
This action cannot be undone. This will permanently delete your account and all
associated data.
</div>
<Alert variant="destructive">
<AlertTitle>{t('common.warning')}</AlertTitle>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
{t('leftSidebar.deleteAccountWarningDetails')}
Deleting your account will remove all your data including SSH hosts,
configurations, and settings.
This action is irreversible.
</AlertDescription>
</Alert>
{deleteError && (
<Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
@@ -627,21 +628,23 @@ export function LeftSidebar({
<form onSubmit={handleDeleteAccount} className="space-y-4">
{isAdmin && adminCount <= 1 && (
<Alert variant="destructive">
<AlertTitle>{t('leftSidebar.cannotDeleteAccount')}</AlertTitle>
<AlertTitle>Cannot Delete Account</AlertTitle>
<AlertDescription>
{t('leftSidebar.lastAdminWarning')}
You are the last admin user. You cannot delete your account as this
would leave the system without any administrators.
Please make another user an admin first, or contact system support.
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="delete-password">{t('leftSidebar.confirmPassword')}</Label>
<Label htmlFor="delete-password">Confirm Password</Label>
<Input
id="delete-password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder={t('placeholders.confirmPassword')}
placeholder="Enter your password to confirm"
required
disabled={isAdmin && adminCount <= 1}
/>
@@ -654,7 +657,7 @@ export function LeftSidebar({
className="flex-1"
disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)}
>
{deleteLoading ? t('leftSidebar.deleting') : t('leftSidebar.deleteAccount')}
{deleteLoading ? "Deleting..." : "Delete Account"}
</Button>
<Button
type="button"
@@ -665,7 +668,7 @@ export function LeftSidebar({
setDeleteError(null);
}}
>
{t('leftSidebar.cancel')}
Cancel
</Button>
</div>
</form>

View File

@@ -143,6 +143,7 @@ interface UserInfo {
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
}
interface UserCount {
@@ -897,7 +898,7 @@ export async function setupTOTP(): Promise<{ secret: string; qr_code: string }>
const response = await authApi.post('/users/totp/setup');
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
handleApiError(error as AxiosError, 'setup TOTP');
throw error;
}
}
@@ -907,7 +908,7 @@ export async function enableTOTP(totp_code: string): Promise<{ message: string;
const response = await authApi.post('/users/totp/enable', { totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
handleApiError(error as AxiosError, 'enable TOTP');
throw error;
}
}
@@ -917,7 +918,7 @@ export async function disableTOTP(password?: string, totp_code?: string): Promis
const response = await authApi.post('/users/totp/disable', { password, totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
handleApiError(error as AxiosError, 'disable TOTP');
throw error;
}
}
@@ -927,7 +928,7 @@ export async function verifyTOTPLogin(temp_token: string, totp_code: string): Pr
const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
handleApiError(error as AxiosError, 'verify TOTP login');
throw error;
}
}
@@ -937,7 +938,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string)
const response = await authApi.post('/users/totp/backup-codes', { password, totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError);
handleApiError(error as AxiosError, 'generate backup codes');
throw error;
}
}