From e346859902660bc8fa564f1f1b4a36af2fb67cc2 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 2 Sep 2025 20:17:42 -0500 Subject: [PATCH] Migrate everything to alert system, update user.ts for OIDC updates. --- src/backend/database/routes/users.ts | 43 +++-- src/components/ui/sidebar.tsx | 53 +++--- src/ui/Admin/AdminSettings.tsx | 27 +-- .../Host Manager/HostManagerHostEditor.tsx | 174 ++++++++++-------- .../Host Manager/HostManagerHostViewer.tsx | 55 +++--- src/ui/Homepage/Homepage.tsx | 24 ++- src/ui/User/PasswordReset.tsx | 43 ++--- 7 files changed, 228 insertions(+), 191 deletions(-) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 3456aee3..e976da89 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -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(', ')}`); diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index fe6a02e8..291c11b2 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -178,34 +178,35 @@ function Sidebar({ ) } - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - -
{children}
-
-
- ) - } + // Commented out mobile behavior to keep sidebar always visible + // if (isMobile) { + // return ( + // + // + // + // Sidebar + // Displays the mobile sidebar. + // + //
{children}
+ //
+ //
+ // ) + // } return ( - - {oidcSuccess && ( - - Success - {oidcSuccess} - - )} @@ -404,12 +398,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. {makeAdminError} )} - {makeAdminSuccess && ( - - Success - {makeAdminSuccess} - - )} + diff --git a/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx b/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx index d341ef16..7dc84072 100644 --- a/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx +++ b/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx @@ -1,7 +1,6 @@ import {zodResolver} from "@hookform/resolvers/zod" import {Controller, useForm} from "react-hook-form" import {z} from "zod" -import {useTranslation} from "react-i18next" import {Button} from "@/components/ui/button.tsx" import { @@ -51,7 +50,6 @@ interface SSHManagerHostEditorProps { } export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) { - const {t} = useTranslation(); const [hosts, setHosts] = useState([]); const [folders, setFolders] = useState([]); const [sshConfigurations, setSshConfigurations] = useState([]); @@ -129,7 +127,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos if (!data.password || data.password.trim() === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: t('hosts.passwordRequired'), + message: "Password is required when using password authentication", path: ['password'] }); } @@ -137,14 +135,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos if (!data.key) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: t('hosts.sshKeyRequired'), + message: "SSH Private Key is required when using key authentication", path: ['key'] }); } if (!data.keyType) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: t('hosts.keyTypeRequired'), + message: "Key Type is required when using key authentication", path: ['keyType'] }); } @@ -256,7 +254,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } catch (error) { - alert(t('errors.saveError')); + alert('Failed to save host. Please try again.'); } }; @@ -301,7 +299,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos }, [folderDropdownOpen]); const keyTypeOptions = [ - {value: 'auto', label: t('common.autoDetect')}, + {value: 'auto', label: 'Auto-detect'}, {value: 'ssh-rsa', label: 'RSA'}, {value: 'ssh-ed25519', label: 'ED25519'}, {value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'}, @@ -395,20 +393,20 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos - {t('common.settings')} - {t('nav.terminal')} - {t('nav.tunnels')} - {t('nav.fileManager')} + General + Terminal + Tunnel + File Manager - {t('hosts.connectionDetails')} + Connection Details
( - {t('hosts.ipAddress')} + IP @@ -421,7 +419,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="port" render={({field}) => ( - {t('hosts.port')} + Port @@ -434,24 +432,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="username" render={({field}) => ( - {t('common.username')} + Username - + )} />
- {t('hosts.organization')} + Organization
( - {t('hosts.hostName')} + Name - + )} @@ -462,11 +460,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="folder" render={({field}) => ( - {t('hosts.folder')} + Folder ( - {t('hosts.tags')} + Tags
@@ -543,7 +541,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos field.onChange(field.value.slice(0, -1)); } }} - placeholder={t('hosts.addTags')} + placeholder="add tags (space to add)" />
@@ -567,7 +565,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos )} />
- {t('hosts.authentication')} + Authentication { @@ -577,8 +575,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos className="flex-1 flex flex-col h-full min-h-0" > - {t('hosts.password')} - {t('hosts.key')} + Password + Key ( - {t('hosts.password')} + Password - + )} @@ -601,7 +599,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="key" render={({field}) => ( - {t('hosts.sshPrivateKey')} + SSH Private Key
- {field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')} + {field.value ? (editingHost ? 'Update Key' : field.value.name) : 'Upload'}
@@ -634,10 +632,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="keyPassword" render={({field}) => ( - {t('hosts.keyPassword')} + Key Password @@ -650,7 +648,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="keyType" render={({field}) => ( - {t('hosts.keyType')} + Key Type
@@ -795,8 +810,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.sourcePort`} render={({field: sourcePortField}) => ( - {t('hosts.sourcePort')} - {t('hosts.sourcePortDescription')} + Source Port + (Source refers to the Current + Connection Details in the + General tab) @@ -809,7 +826,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.endpointPort`} render={({field: endpointPortField}) => ( - {t('hosts.endpointPort')} + Endpoint Port (Remote) ( - {t('hosts.endpointSshConfiguration')} + Endpoint SSH + Configuration { sshConfigInputRefs.current[index] = el; }} - placeholder={t('placeholders.sshConfig')} + placeholder="endpoint ssh configuration" className="min-h-[40px]" autoComplete="off" value={endpointHostField.value} @@ -877,10 +895,12 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos

- {t('hosts.tunnelForwardDescription', { - sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22', - endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224' - })} + This tunnel will forward traffic from + port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on + the source machine (current connection details + in general tab) to + port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on + the endpoint machine.

@@ -889,13 +909,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.maxRetries`} render={({field: maxRetriesField}) => ( - {t('hosts.maxRetries', 'Max Retries')} + Max Retries - {t('hosts.maxRetriesDescription')} + Maximum number of retry attempts + for tunnel connection. )} @@ -905,13 +926,15 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.retryInterval`} render={({field: retryIntervalField}) => ( - {t('hosts.retryInterval')} + Retry Interval + (seconds) - {t('hosts.retryIntervalDescription')} + Time to wait between retry + attempts. )} @@ -921,7 +944,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.autoStart`} render={({field}) => ( - {t('hosts.autoStartContainer')} + Auto Start on Container + Launch - {t('hosts.autoStartDesc')} + Automatically start this tunnel + when the container launches. )} @@ -951,7 +976,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos }]); }} > - {t('hosts.addConnection')} + Add Tunnel Connection
@@ -969,7 +994,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="enableFileManager" render={({field}) => ( - {t('hosts.enableFileManager')} + Enable File Manager - {t('hosts.enableFileManagerDesc')} + Enable/disable host visibility in File Manager tab. )} @@ -990,11 +1015,12 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="defaultPath" render={({field}) => ( - {t('hosts.defaultPath')} + Default Path - + - {t('hosts.defaultPathDesc')} + Set default directory shown when connected via + File Manager )} /> @@ -1013,7 +1039,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos transform: 'translateY(8px)' }} > - {editingHost ? t('hosts.updateHost') : t('hosts.addHost')} + {editingHost ? "Update Host" : "Add Host"} diff --git a/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx index ae8d1281..476eb895 100644 --- a/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx +++ b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx @@ -7,7 +7,6 @@ import {Input} from "@/components/ui/input"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts"; -import {useTranslation} from "react-i18next"; import { Edit, Trash2, @@ -48,7 +47,6 @@ interface SSHManagerHostViewerProps { } export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { - const {t} = useTranslation(); const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -66,20 +64,20 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { setHosts(data); setError(null); } catch (err) { - setError(t('hosts.failedToLoadHosts')); + setError('Failed to load hosts'); } finally { setLoading(false); } }; const handleDelete = async (hostId: number, hostName: string) => { - if (window.confirm(t('hosts.confirmDelete', { name: hostName }))) { + if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) { try { await deleteSSHHost(hostId); await fetchHosts(); window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } catch (err) { - alert(t('hosts.failedToDeleteHost')); + alert('Failed to delete host'); } } }; @@ -100,32 +98,32 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { const data = JSON.parse(text); if (!Array.isArray(data.hosts) && !Array.isArray(data)) { - throw new Error(t('hosts.jsonMustContainHosts')); + throw new Error('JSON must contain a "hosts" array or be an array of hosts'); } const hostsArray = Array.isArray(data.hosts) ? data.hosts : data; if (hostsArray.length === 0) { - throw new Error(t('hosts.noHostsInJson')); + throw new Error('No hosts found in JSON file'); } if (hostsArray.length > 100) { - throw new Error(t('hosts.maxHostsAllowed')); + throw new Error('Maximum 100 hosts allowed per import'); } const result = await bulkImportSSHHosts(hostsArray); 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') : '')); + alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`); await fetchHosts(); window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } else { - alert(`${t('hosts.importFailed')}: ${result.errors.join('\n')}`); + alert(`Import failed: ${result.errors.join('\n')}`); } } catch (err) { - const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson'); - alert(`${t('hosts.importError')}: ${errorMessage}`); + const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file'; + alert(`Import error: ${errorMessage}`); } finally { setImporting(false); event.target.value = ''; @@ -165,7 +163,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { const grouped: { [key: string]: SSHHost[] } = {}; filteredAndSortedHosts.forEach(host => { - const folder = host.folder || t('hosts.uncategorized'); + const folder = host.folder || 'Uncategorized'; if (!grouped[folder]) { grouped[folder] = []; } @@ -173,9 +171,8 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { }); const sortedFolders = Object.keys(grouped).sort((a, b) => { - const uncategorized = t('hosts.uncategorized'); - if (a === uncategorized) return -1; - if (b === uncategorized) return 1; + if (a === 'Uncategorized') return -1; + if (b === 'Uncategorized') return 1; return a.localeCompare(b); }); @@ -192,7 +189,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
-

{t('hosts.loadingHosts')}

+

Loading hosts...

); @@ -204,7 +201,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {

{error}

@@ -216,9 +213,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
-

{t('hosts.noHosts')}

+

No SSH Hosts

- {t('hosts.noHostsMessage')} + You haven't added any SSH hosts yet. Click "Add Host" to get started.

@@ -229,9 +226,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
-

{t('hosts.sshHosts')}

+

SSH Hosts

- {t('hosts.hostsCount', { count: filteredAndSortedHosts.length })} + {filteredAndSortedHosts.length} hosts

@@ -245,15 +242,15 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { onClick={() => document.getElementById('json-import-input')?.click()} disabled={importing} > - {importing ? t('hosts.importing') : t('hosts.importJson')} + {importing ? 'Importing...' : 'Import JSON'}
-

{t('hosts.importJsonTitle')}

+

Import SSH Hosts from JSON

- {t('hosts.importJsonDesc')} + Upload a JSON file to bulk import multiple SSH hosts (max 100).

@@ -321,7 +318,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { URL.revokeObjectURL(url); }} > - {t('hosts.downloadSample')} + Download Sample
@@ -353,7 +350,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
setSearchQuery(e.target.value)} className="pl-10" diff --git a/src/ui/Homepage/Homepage.tsx b/src/ui/Homepage/Homepage.tsx index d96d86e5..c563e4a0 100644 --- a/src/ui/Homepage/Homepage.tsx +++ b/src/ui/Homepage/Homepage.tsx @@ -72,13 +72,20 @@ export function Homepage({ } }, [isAuthenticated]); + const topOffset = isTopbarOpen ? 66 : 0; + const topPadding = isTopbarOpen ? 66 : 0; + return (
+ className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out" + style={{ paddingTop: `${topPadding}px` }}> {!loggedIn ? ( -
+
) : ( -
-
+
+
diff --git a/src/ui/User/PasswordReset.tsx b/src/ui/User/PasswordReset.tsx index 7f111614..19fb4724 100644 --- a/src/ui/User/PasswordReset.tsx +++ b/src/ui/User/PasswordReset.tsx @@ -6,7 +6,6 @@ import {Label} from "@/components/ui/label.tsx"; 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"; interface PasswordResetProps { userInfo: { @@ -18,7 +17,6 @@ interface PasswordResetProps { } export function PasswordReset({userInfo}: PasswordResetProps) { - const {t} = useTranslation(); const [error, setError] = useState(null); const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate"); @@ -37,7 +35,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) { setResetStep("verify"); setError(null); } catch (err: any) { - setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset')); + setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset"); } finally { setResetLoading(false); } @@ -62,7 +60,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) { setResetStep("newPassword"); setError(null); } catch (err: any) { - setError(err?.response?.data?.error || t('errors.failedVerifyCode')); + setError(err?.response?.data?.error || "Failed to verify reset code"); } finally { setResetLoading(false); } @@ -73,13 +71,13 @@ export function PasswordReset({userInfo}: PasswordResetProps) { setResetLoading(true); if (newPassword !== confirmPassword) { - setError(t('errors.passwordMismatch')); + setError("Passwords do not match"); setResetLoading(false); return; } if (newPassword.length < 6) { - setError(t('errors.weakPassword')); + setError("Password must be at least 6 characters long"); setResetLoading(false); return; } @@ -96,7 +94,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) { setResetSuccess(true); } catch (err: any) { - setError(err?.response?.data?.error || t('errors.failedCompleteReset')); + setError(err?.response?.data?.error || "Failed to complete password reset"); } finally { setResetLoading(false); } @@ -117,7 +115,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) { Password - {t('profile.changePassword')} + Change your account password @@ -131,7 +129,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) { disabled={resetLoading || !userInfo.username.trim()} onClick={handleInitiatePasswordReset} > - {resetLoading ? Spinner : t('auth.sendResetCode')} + {resetLoading ? Spinner : "Send Reset Code"}
@@ -140,11 +138,12 @@ export function PasswordReset({userInfo}: PasswordResetProps) { {resetStep === "verify" && ( <>
-

{t('auth.enterResetCode')}: {userInfo.username}

+

Enter the 6-digit code from the docker container logs for + user: {userInfo.username}

- + - {resetLoading ? Spinner : t('auth.verifyCode')} + {resetLoading ? Spinner : "Verify Code"}
@@ -184,9 +183,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) { {resetSuccess && ( <> - {t('auth.passwordResetSuccess')} + Success! - {t('auth.passwordResetSuccessDesc')} + Your password has been successfully reset! You can now log in + with your new password. @@ -195,11 +195,12 @@ export function PasswordReset({userInfo}: PasswordResetProps) { {resetStep === "newPassword" && !resetSuccess && ( <>
-

{t('auth.enterNewPassword')}: {userInfo.username}

+

Enter your new password for + user: {userInfo.username}

- +
- + - {resetLoading ? Spinner : t('auth.resetPassword')} + {resetLoading ? Spinner : "Reset Password"}
)} {error && ( - {t('common.error')} + Error {error} )}