Migrate everything to alert system, update user.ts for OIDC updates.
This commit is contained in:
@@ -46,11 +46,19 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
jwks = await response.json();
|
||||
jwksUrl = url;
|
||||
break;
|
||||
const jwksData = await response.json() as any;
|
||||
if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
|
||||
jwks = jwksData;
|
||||
jwksUrl = url;
|
||||
break;
|
||||
} else {
|
||||
logger.error(`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`);
|
||||
}
|
||||
} else {
|
||||
logger.error(`JWKS fetch failed from ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`JWKS fetch error from ${url}:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -59,12 +67,16 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
|
||||
throw new Error('Failed to fetch JWKS from any URL');
|
||||
}
|
||||
|
||||
if (!jwks.keys || !Array.isArray(jwks.keys)) {
|
||||
throw new Error(`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`);
|
||||
}
|
||||
|
||||
const header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString());
|
||||
const keyId = header.kid;
|
||||
|
||||
const publicKey = jwks.keys.find((key: any) => key.kid === keyId);
|
||||
if (!publicKey) {
|
||||
throw new Error(`No matching public key found for key ID: ${keyId}`);
|
||||
throw new Error(`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: any) => k.kid).join(', ')}`);
|
||||
}
|
||||
|
||||
const {importJWK, jwtVerify} = await import('jose');
|
||||
@@ -400,8 +412,19 @@ router.get('/oidc/callback', async (req, res) => {
|
||||
if (tokenData.id_token) {
|
||||
try {
|
||||
userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id);
|
||||
logger.info('Successfully verified ID token and extracted user info');
|
||||
} catch (error) {
|
||||
logger.error('OIDC token verification failed, trying userinfo endpoints', error);
|
||||
try {
|
||||
const parts = tokenData.id_token.split('.');
|
||||
if (parts.length === 3) {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
userInfo = payload;
|
||||
logger.info('Successfully decoded ID token payload without verification');
|
||||
}
|
||||
} catch (decodeError) {
|
||||
logger.error('Failed to decode ID token payload:', decodeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,18 +450,6 @@ router.get('/oidc/callback', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
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(', ')}`);
|
||||
|
||||
@@ -178,34 +178,35 @@ function Sidebar({
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
// Commented out mobile behavior to keep sidebar always visible
|
||||
// if (isMobile) {
|
||||
// return (
|
||||
// <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
// <SheetContent
|
||||
// data-sidebar="sidebar"
|
||||
// data-slot="sidebar"
|
||||
// data-mobile="true"
|
||||
// className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
// style={
|
||||
// {
|
||||
// "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
// } as React.CSSProperties
|
||||
// }
|
||||
// side={side}
|
||||
// >
|
||||
// <SheetHeader className="sr-only">
|
||||
// <SheetTitle>Sidebar</SheetTitle>
|
||||
// <SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
// </SheetHeader>
|
||||
// <div className="flex h-full w-full flex-col">{children}</div>
|
||||
// </SheetContent>
|
||||
// </Sheet>
|
||||
// )
|
||||
// }
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
className="group peer text-sidebar-foreground block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
@@ -227,7 +228,7 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
|
||||
@@ -52,7 +52,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
scopes: 'openid email profile',
|
||||
userinfo_url: ''
|
||||
});
|
||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
@@ -145,7 +146,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
setOidcConfig(prev => ({...prev, [field]: value}));
|
||||
};
|
||||
|
||||
const makeUserAdmin = async (e: React.FormEvent) => {
|
||||
const handleMakeUserAdmin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newAdminUsername.trim()) return;
|
||||
setMakeAdminLoading(true);
|
||||
@@ -164,23 +165,25 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
}
|
||||
};
|
||||
|
||||
const removeAdminStatus = async (username: string) => {
|
||||
const handleRemoveAdminStatus = async (username: string) => {
|
||||
if (!confirm(`Remove admin status from ${username}?`)) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await removeAdminStatus(username);
|
||||
fetchUsers();
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove admin status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (username: string) => {
|
||||
const handleDeleteUser = async (username: string) => {
|
||||
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteUser(username);
|
||||
fetchUsers();
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete user:', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -296,9 +299,15 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">Scopes</Label>
|
||||
<Input id="scopes" value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
|
||||
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
|
||||
placeholder="openid email profile" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userinfo_url">Overide User Info URL (not required)</Label>
|
||||
<Input id="userinfo_url" value={oidcConfig.userinfo_url}
|
||||
onChange={(e) => handleOIDCConfigChange('userinfo_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/userinfo/"/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1"
|
||||
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
|
||||
@@ -310,7 +319,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
scopes: 'openid email profile',
|
||||
userinfo_url: ''
|
||||
})}>Reset</Button>
|
||||
</div>
|
||||
|
||||
@@ -357,7 +367,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<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"
|
||||
disabled={user.is_admin}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
@@ -377,7 +387,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<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">Make User Admin</h4>
|
||||
<form onSubmit={makeUserAdmin} className="space-y-4">
|
||||
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-admin-username">Username</Label>
|
||||
<div className="flex gap-2">
|
||||
@@ -426,7 +436,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<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">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Remove Admin
|
||||
|
||||
@@ -21,7 +21,6 @@ import React, {useEffect, useRef, useState} from "react";
|
||||
import {Switch} from "@/components/ui/switch.tsx";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
|
||||
import {toast} from "sonner";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -256,9 +255,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
toast.success(editingHost ? t('hosts.hostUpdated') : t('hosts.hostAdded'));
|
||||
} catch (error) {
|
||||
toast.error(t('errors.saveError'));
|
||||
alert(t('errors.saveError'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/co
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {toast} from "sonner";
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
@@ -79,9 +78,8 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
await deleteSSHHost(hostId);
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
toast.success(t('hosts.hostDeleted'));
|
||||
} catch (err) {
|
||||
toast.error(t('hosts.failedToDeleteHost'));
|
||||
alert(t('hosts.failedToDeleteHost'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -118,19 +116,16 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const result = await bulkImportSSHHosts(hostsArray);
|
||||
|
||||
if (result.success > 0) {
|
||||
toast.success(t('hosts.importCompleted', { success: result.success, failed: result.failed }));
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(`Errors: ${result.errors.join(', ')}`);
|
||||
}
|
||||
alert(t('hosts.importCompleted', { success: result.success, failed: result.failed }) + (result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} else {
|
||||
toast.error(`${t('hosts.importFailed')}: ${result.errors.join(', ')}`);
|
||||
alert(`${t('hosts.importFailed')}: ${result.errors.join('\n')}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson');
|
||||
toast.error(`${t('hosts.importError')}: ${errorMessage}`);
|
||||
alert(`${t('hosts.importError')}: ${errorMessage}`);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
event.target.value = '';
|
||||
|
||||
@@ -72,13 +72,20 @@ export function Homepage({
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const topOffset = isTopbarOpen ? 66 : 0;
|
||||
const topPadding = isTopbarOpen ? 66 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full min-h-svh relative transition-[padding-top] duration-200 ease-linear ${
|
||||
isTopbarOpen ? 'pt-[66px]' : 'pt-2'
|
||||
}`}>
|
||||
className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out"
|
||||
style={{ paddingTop: `${topPadding}px` }}>
|
||||
{!loggedIn ? (
|
||||
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute left-0 w-full flex items-center justify-center transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
top: `${topOffset}px`,
|
||||
height: `calc(100% - ${topOffset}px)`
|
||||
}}>
|
||||
<HomepageAuth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
@@ -92,8 +99,13 @@ export function Homepage({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
|
||||
<div
|
||||
className="absolute left-0 w-full flex items-center justify-center transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
top: `${topOffset}px`,
|
||||
height: `calc(100% - ${topOffset}px)`
|
||||
}}>
|
||||
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
|
||||
<div className="flex flex-col items-center gap-6 w-[400px]">
|
||||
<div
|
||||
className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg">
|
||||
|
||||
@@ -7,7 +7,6 @@ import {Input} from "@/components/ui/input.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {toast} from "sonner";
|
||||
|
||||
interface PasswordResetProps {
|
||||
userInfo: {
|
||||
@@ -37,11 +36,8 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
const result = await initiatePasswordReset(userInfo.username);
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
toast.success(t('auth.resetCodeSent'));
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.error || err?.message || t('errors.failedPasswordReset');
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -65,11 +61,8 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setTempToken(response.tempToken);
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
toast.success(t('auth.codeVerified'));
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.error || t('errors.failedVerifyCode');
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
setError(err?.response?.data?.error || t('errors.failedVerifyCode'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -80,17 +73,13 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
const errorMessage = t('errors.passwordMismatch');
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
setError(t('errors.passwordMismatch'));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
const errorMessage = t('errors.weakPassword');
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
setError(t('errors.weakPassword'));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -106,11 +95,8 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setError(null);
|
||||
|
||||
setResetSuccess(true);
|
||||
toast.success(t('auth.passwordResetSuccess'));
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.error || t('errors.failedCompleteReset');
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
setError(err?.response?.data?.error || t('errors.failedCompleteReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user