Migrate everything to alert system, update user.ts for OIDC updates.

This commit is contained in:
LukeGus
2025-09-02 20:17:42 -05:00
parent 5f797628ac
commit e346859902
7 changed files with 228 additions and 191 deletions

View File

@@ -46,11 +46,19 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
try { try {
const response = await fetch(url); const response = await fetch(url);
if (response.ok) { if (response.ok) {
jwks = await response.json(); const jwksData = await response.json() as any;
jwksUrl = url; if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
break; 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) { } catch (error) {
logger.error(`JWKS fetch error from ${url}:`, error);
continue; continue;
} }
} }
@@ -59,12 +67,16 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
throw new Error('Failed to fetch JWKS from any URL'); 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 header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString());
const keyId = header.kid; const keyId = header.kid;
const publicKey = jwks.keys.find((key: any) => key.kid === keyId); const publicKey = jwks.keys.find((key: any) => key.kid === keyId);
if (!publicKey) { 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'); const {importJWK, jwtVerify} = await import('jose');
@@ -400,8 +412,19 @@ router.get('/oidc/callback', async (req, res) => {
if (tokenData.id_token) { if (tokenData.id_token) {
try { try {
userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id); userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id);
logger.info('Successfully verified ID token and extracted user info');
} catch (error) { } catch (error) {
logger.error('OIDC token verification failed, trying userinfo endpoints', 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) { if (!userInfo) {
logger.error('Failed to get user information from all sources'); logger.error('Failed to get user information from all sources');
logger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`); logger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`);

View File

@@ -178,34 +178,35 @@ function Sidebar({
) )
} }
if (isMobile) { // Commented out mobile behavior to keep sidebar always visible
return ( // if (isMobile) {
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> // return (
<SheetContent // <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
data-sidebar="sidebar" // <SheetContent
data-slot="sidebar" // data-sidebar="sidebar"
data-mobile="true" // data-slot="sidebar"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" // data-mobile="true"
style={ // className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
{ // style={
"--sidebar-width": SIDEBAR_WIDTH_MOBILE, // {
} as React.CSSProperties // "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} // } as React.CSSProperties
side={side} // }
> // side={side}
<SheetHeader className="sr-only"> // >
<SheetTitle>Sidebar</SheetTitle> // <SheetHeader className="sr-only">
<SheetDescription>Displays the mobile sidebar.</SheetDescription> // <SheetTitle>Sidebar</SheetTitle>
</SheetHeader> // <SheetDescription>Displays the mobile sidebar.</SheetDescription>
<div className="flex h-full w-full flex-col">{children}</div> // </SheetHeader>
</SheetContent> // <div className="flex h-full w-full flex-col">{children}</div>
</Sheet> // </SheetContent>
) // </Sheet>
} // )
// }
return ( return (
<div <div
className="group peer text-sidebar-foreground hidden md:block" className="group peer text-sidebar-foreground block"
data-state={state} data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""} data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant} data-variant={variant}
@@ -227,7 +228,7 @@ function Sidebar({
<div <div
data-slot="sidebar-container" data-slot="sidebar-container"
className={cn( 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" side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",

View File

@@ -16,6 +16,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table.tsx"; } from "@/components/ui/table.tsx";
import {Shield, Trash2, Users} from "lucide-react"; import {Shield, Trash2, Users} from "lucide-react";
import {toast} from "sonner";
import { import {
getOIDCConfig, getOIDCConfig,
getRegistrationAllowed, getRegistrationAllowed,
@@ -57,7 +58,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
}); });
const [oidcLoading, setOidcLoading] = React.useState(false); const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null); 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,7 +69,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
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 [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,7 +120,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
e.preventDefault(); e.preventDefault();
setOidcLoading(true); setOidcLoading(true);
setOidcError(null); 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]);
@@ -134,7 +132,7 @@ 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 || "Failed to update OIDC configuration"); setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally { } finally {
@@ -151,11 +149,10 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
if (!newAdminUsername.trim()) return; if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true); setMakeAdminLoading(true);
setMakeAdminError(null); 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) {
@@ -170,9 +167,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
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 (err: any) { } catch (err: any) {
console.error('Failed to remove admin status:', err); console.error('Failed to remove admin status:', err);
toast.error('Failed to remove admin status');
} }
}; };
@@ -181,9 +180,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
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 (err: any) { } catch (err: any) {
console.error('Failed to delete user:', err); console.error('Failed to delete user:', err);
toast.error('Failed to delete user');
} }
}; };
@@ -323,13 +324,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
userinfo_url: '' userinfo_url: ''
})}>Reset</Button> })}>Reset</Button>
</div> </div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form> </form>
</div> </div>
</TabsContent> </TabsContent>
@@ -404,12 +398,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<AlertDescription>{makeAdminError}</AlertDescription> <AlertDescription>{makeAdminError}</AlertDescription>
</Alert> </Alert>
)} )}
{makeAdminSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
</form> </form>
</div> </div>

View File

@@ -1,7 +1,6 @@
import {zodResolver} from "@hookform/resolvers/zod" import {zodResolver} from "@hookform/resolvers/zod"
import {Controller, useForm} from "react-hook-form" import {Controller, useForm} from "react-hook-form"
import {z} from "zod" import {z} from "zod"
import {useTranslation} from "react-i18next"
import {Button} from "@/components/ui/button.tsx" import {Button} from "@/components/ui/button.tsx"
import { import {
@@ -51,7 +50,6 @@ interface SSHManagerHostEditorProps {
} }
export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) { export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [folders, setFolders] = useState<string[]>([]); const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]); const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
@@ -129,7 +127,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
if (!data.password || data.password.trim() === '') { if (!data.password || data.password.trim() === '') {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: t('hosts.passwordRequired'), message: "Password is required when using password authentication",
path: ['password'] path: ['password']
}); });
} }
@@ -137,14 +135,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
if (!data.key) { if (!data.key) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: t('hosts.sshKeyRequired'), message: "SSH Private Key is required when using key authentication",
path: ['key'] path: ['key']
}); });
} }
if (!data.keyType) { if (!data.keyType) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: t('hosts.keyTypeRequired'), message: "Key Type is required when using key authentication",
path: ['keyType'] path: ['keyType']
}); });
} }
@@ -256,7 +254,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) { } 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]); }, [folderDropdownOpen]);
const keyTypeOptions = [ const keyTypeOptions = [
{value: 'auto', label: t('common.autoDetect')}, {value: 'auto', label: 'Auto-detect'},
{value: 'ssh-rsa', label: 'RSA'}, {value: 'ssh-rsa', label: 'RSA'},
{value: 'ssh-ed25519', label: 'ED25519'}, {value: 'ssh-ed25519', label: 'ED25519'},
{value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'}, {value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'},
@@ -395,20 +393,20 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2"> <ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full"> <Tabs defaultValue="general" className="w-full">
<TabsList> <TabsList>
<TabsTrigger value="general">{t('common.settings')}</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="terminal">{t('nav.terminal')}</TabsTrigger> <TabsTrigger value="terminal">Terminal</TabsTrigger>
<TabsTrigger value="tunnel">{t('nav.tunnels')}</TabsTrigger> <TabsTrigger value="tunnel">Tunnel</TabsTrigger>
<TabsTrigger value="file_manager">{t('nav.fileManager')}</TabsTrigger> <TabsTrigger value="file_manager">File Manager</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="general" className="pt-2"> <TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">{t('hosts.connectionDetails')}</FormLabel> <FormLabel className="mb-3 font-bold">Connection Details</FormLabel>
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="ip" name="ip"
render={({field}) => ( render={({field}) => (
<FormItem className="col-span-5"> <FormItem className="col-span-5">
<FormLabel>{t('hosts.ipAddress')}</FormLabel> <FormLabel>IP</FormLabel>
<FormControl> <FormControl>
<Input placeholder="127.0.0.1" {...field} /> <Input placeholder="127.0.0.1" {...field} />
</FormControl> </FormControl>
@@ -421,7 +419,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="port" name="port"
render={({field}) => ( render={({field}) => (
<FormItem className="col-span-1"> <FormItem className="col-span-1">
<FormLabel>{t('hosts.port')}</FormLabel> <FormLabel>Port</FormLabel>
<FormControl> <FormControl>
<Input placeholder="22" {...field} /> <Input placeholder="22" {...field} />
</FormControl> </FormControl>
@@ -434,24 +432,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="username" name="username"
render={({field}) => ( render={({field}) => (
<FormItem className="col-span-6"> <FormItem className="col-span-6">
<FormLabel>{t('common.username')}</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input placeholder={t('placeholders.username')} {...field} /> <Input placeholder="username" {...field} />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.organization')}</FormLabel> <FormLabel className="mb-3 mt-3 font-bold">Organization</FormLabel>
<div className="grid grid-cols-26 gap-4"> <div className="grid grid-cols-26 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({field}) => ( render={({field}) => (
<FormItem className="col-span-10"> <FormItem className="col-span-10">
<FormLabel>{t('hosts.hostName')}</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder={t('placeholders.hostname')} {...field} /> <Input placeholder="host name" {...field} />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -462,11 +460,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="folder" name="folder"
render={({field}) => ( render={({field}) => (
<FormItem className="col-span-10 relative"> <FormItem className="col-span-10 relative">
<FormLabel>{t('hosts.folder')}</FormLabel> <FormLabel>Folder</FormLabel>
<FormControl> <FormControl>
<Input <Input
ref={folderInputRef} ref={folderInputRef}
placeholder={t('placeholders.folder')} placeholder="folder"
className="min-h-[40px]" className="min-h-[40px]"
autoComplete="off" autoComplete="off"
value={field.value} value={field.value}
@@ -507,7 +505,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="tags" name="tags"
render={({field}) => ( render={({field}) => (
<FormItem className="col-span-10 overflow-visible"> <FormItem className="col-span-10 overflow-visible">
<FormLabel>{t('hosts.tags')}</FormLabel> <FormLabel>Tags</FormLabel>
<FormControl> <FormControl>
<div <div
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]"> className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
@@ -543,7 +541,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
field.onChange(field.value.slice(0, -1)); field.onChange(field.value.slice(0, -1));
} }
}} }}
placeholder={t('hosts.addTags')} placeholder="add tags (space to add)"
/> />
</div> </div>
</FormControl> </FormControl>
@@ -567,7 +565,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
)} )}
/> />
</div> </div>
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.authentication')}</FormLabel> <FormLabel className="mb-3 mt-3 font-bold">Authentication</FormLabel>
<Tabs <Tabs
value={authTab} value={authTab}
onValueChange={(value) => { onValueChange={(value) => {
@@ -577,8 +575,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
className="flex-1 flex flex-col h-full min-h-0" className="flex-1 flex flex-col h-full min-h-0"
> >
<TabsList> <TabsList>
<TabsTrigger value="password">{t('hosts.password')}</TabsTrigger> <TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="key">{t('hosts.key')}</TabsTrigger> <TabsTrigger value="key">Key</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="password"> <TabsContent value="password">
<FormField <FormField
@@ -586,9 +584,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('hosts.password')}</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder={t('placeholders.password')} {...field} /> <Input type="password" placeholder="password" {...field} />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -601,7 +599,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="key" name="key"
render={({field}) => ( render={({field}) => (
<FormItem className="col-span-4 overflow-hidden min-w-0"> <FormItem className="col-span-4 overflow-hidden min-w-0">
<FormLabel>{t('hosts.sshPrivateKey')}</FormLabel> <FormLabel>SSH Private Key</FormLabel>
<FormControl> <FormControl>
<div className="relative min-w-0"> <div className="relative min-w-0">
<input <input
@@ -621,7 +619,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
> >
<span className="block w-full truncate" <span className="block w-full truncate"
title={field.value?.name || 'Upload'}> title={field.value?.name || 'Upload'}>
{field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')} {field.value ? (editingHost ? 'Update Key' : field.value.name) : 'Upload'}
</span> </span>
</Button> </Button>
</div> </div>
@@ -634,10 +632,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="keyPassword" name="keyPassword"
render={({field}) => ( render={({field}) => (
<FormItem className="col-span-8"> <FormItem className="col-span-8">
<FormLabel>{t('hosts.keyPassword')}</FormLabel> <FormLabel>Key Password</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder={t('placeholders.keyPassword')} placeholder="key password"
type="password" type="password"
{...field} {...field}
/> />
@@ -650,7 +648,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="keyType" name="keyType"
render={({field}) => ( render={({field}) => (
<FormItem className="relative col-span-3"> <FormItem className="relative col-span-3">
<FormLabel>{t('hosts.keyType')}</FormLabel> <FormLabel>Key Type</FormLabel>
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<Button <Button
@@ -701,7 +699,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="enableTerminal" name="enableTerminal"
render={({field}) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>{t('hosts.enableTerminal')}</FormLabel> <FormLabel>Enable Terminal</FormLabel>
<FormControl> <FormControl>
<Switch <Switch
checked={field.value} checked={field.value}
@@ -709,7 +707,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('hosts.enableTerminalDesc')} Enable/disable host visibility in Terminal tab.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -721,7 +719,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="enableTunnel" name="enableTunnel"
render={({field}) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>{t('hosts.enableTunnel')}</FormLabel> <FormLabel>Enable Tunnel</FormLabel>
<FormControl> <FormControl>
<Switch <Switch
checked={field.value} checked={field.value}
@@ -729,7 +727,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('hosts.enableTunnelDesc')} Enable/disable host visibility in Tunnel tab.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -739,27 +737,44 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
<> <>
<Alert className="mt-4"> <Alert className="mt-4">
<AlertDescription> <AlertDescription>
<strong>{t('hosts.sshpassRequired')}</strong> <strong>Sshpass Required For Password Authentication</strong>
<div> <div>
{t('hosts.sshpassInstallCommand')} For password-based SSH authentication, sshpass must be installed on
both the local and remote servers. Install with: <code
className="bg-muted px-1 rounded inline">sudo apt install
sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
</div> </div>
<div className="mt-2"> <div className="mt-2">
<strong>{t('hosts.otherInstallMethods')}</strong> <strong>Other installation methods:</strong>
<div> {t('hosts.sshpassOSInstructions.centos')}</div> <div> CentOS/RHEL/Fedora: <code
<div> {t('hosts.sshpassOSInstructions.macos')}</div> className="bg-muted px-1 rounded inline">sudo yum install
<div> {t('hosts.sshpassOSInstructions.windows')}</div> sshpass</code> or <code
className="bg-muted px-1 rounded inline">sudo dnf install
sshpass</code></div>
<div> macOS: <code className="bg-muted px-1 rounded inline">brew
install hudochenkov/sshpass/sshpass</code></div>
<div> Windows: Use WSL or consider SSH key authentication</div>
</div> </div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Alert className="mt-4"> <Alert className="mt-4">
<AlertDescription> <AlertDescription>
<strong>{t('hosts.sshServerConfig')}</strong> <strong>SSH Server Configuration Required</strong>
<div>{t('hosts.sshServerConfigReverse')}</div> <div>For reverse SSH tunnels, the endpoint SSH server must allow:</div>
<div> <code className="bg-muted px-1 rounded inline">{t('hosts.gatewayPorts')}</code></div> <div> <code className="bg-muted px-1 rounded inline">GatewayPorts
<div> <code className="bg-muted px-1 rounded inline">{t('hosts.allowTcpForwarding')}</code></div> yes</code> (bind remote ports)
<div> <code className="bg-muted px-1 rounded inline">{t('hosts.permitRootLogin')}</code></div> </div>
<div className="mt-2">{t('hosts.editSshConfig')}</div> <div> <code className="bg-muted px-1 rounded inline">AllowTcpForwarding
yes</code> (port forwarding)
</div>
<div> <code className="bg-muted px-1 rounded inline">PermitRootLogin
yes</code> (if using root)
</div>
<div className="mt-2">Edit <code
className="bg-muted px-1 rounded inline">/etc/ssh/sshd_config</code> and
restart SSH: <code className="bg-muted px-1 rounded inline">sudo
systemctl restart sshd</code></div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -768,7 +783,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="tunnelConnections" name="tunnelConnections"
render={({field}) => ( render={({field}) => (
<FormItem className="mt-4"> <FormItem className="mt-4">
<FormLabel>{t('hosts.tunnelConnections')}</FormLabel> <FormLabel>Tunnel Connections</FormLabel>
<FormControl> <FormControl>
<div className="space-y-4"> <div className="space-y-4">
{field.value.map((connection, index) => ( {field.value.map((connection, index) => (
@@ -776,7 +791,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
className="p-4 border rounded-lg bg-muted/50"> className="p-4 border rounded-lg bg-muted/50">
<div <div
className="flex items-center justify-between mb-3"> className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold">{t('hosts.connection')} {index + 1}</h4> <h4 className="text-sm font-bold">Connection {index + 1}</h4>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -786,7 +801,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
field.onChange(newConnections); field.onChange(newConnections);
}} }}
> >
{t('hosts.remove')} Remove
</Button> </Button>
</div> </div>
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-4">
@@ -795,8 +810,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.sourcePort`} name={`tunnelConnections.${index}.sourcePort`}
render={({field: sourcePortField}) => ( render={({field: sourcePortField}) => (
<FormItem className="col-span-4"> <FormItem className="col-span-4">
<FormLabel>{t('hosts.sourcePort')} <FormLabel>Source Port
{t('hosts.sourcePortDescription')}</FormLabel> (Source refers to the Current
Connection Details in the
General tab)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="22" {...sourcePortField} /> placeholder="22" {...sourcePortField} />
@@ -809,7 +826,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.endpointPort`} name={`tunnelConnections.${index}.endpointPort`}
render={({field: endpointPortField}) => ( render={({field: endpointPortField}) => (
<FormItem className="col-span-4"> <FormItem className="col-span-4">
<FormLabel>{t('hosts.endpointPort')} <FormLabel>Endpoint Port
(Remote)</FormLabel> (Remote)</FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -824,13 +841,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
render={({field: endpointHostField}) => ( render={({field: endpointHostField}) => (
<FormItem <FormItem
className="col-span-4 relative"> className="col-span-4 relative">
<FormLabel>{t('hosts.endpointSshConfiguration')}</FormLabel> <FormLabel>Endpoint SSH
Configuration</FormLabel>
<FormControl> <FormControl>
<Input <Input
ref={(el) => { ref={(el) => {
sshConfigInputRefs.current[index] = el; sshConfigInputRefs.current[index] = el;
}} }}
placeholder={t('placeholders.sshConfig')} placeholder="endpoint ssh configuration"
className="min-h-[40px]" className="min-h-[40px]"
autoComplete="off" autoComplete="off"
value={endpointHostField.value} value={endpointHostField.value}
@@ -877,10 +895,12 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
</div> </div>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
{t('hosts.tunnelForwardDescription', { This tunnel will forward traffic from
sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22', port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on
endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224' the source machine (current connection details
})} in general tab) to
port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on
the endpoint machine.
</p> </p>
<div className="grid grid-cols-12 gap-4 mt-4"> <div className="grid grid-cols-12 gap-4 mt-4">
@@ -889,13 +909,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.maxRetries`} name={`tunnelConnections.${index}.maxRetries`}
render={({field: maxRetriesField}) => ( render={({field: maxRetriesField}) => (
<FormItem className="col-span-4"> <FormItem className="col-span-4">
<FormLabel>{t('hosts.maxRetries', 'Max Retries')}</FormLabel> <FormLabel>Max Retries</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="3" {...maxRetriesField} /> placeholder="3" {...maxRetriesField} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('hosts.maxRetriesDescription')} Maximum number of retry attempts
for tunnel connection.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -905,13 +926,15 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.retryInterval`} name={`tunnelConnections.${index}.retryInterval`}
render={({field: retryIntervalField}) => ( render={({field: retryIntervalField}) => (
<FormItem className="col-span-4"> <FormItem className="col-span-4">
<FormLabel>{t('hosts.retryInterval')}</FormLabel> <FormLabel>Retry Interval
(seconds)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="10" {...retryIntervalField} /> placeholder="10" {...retryIntervalField} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('hosts.retryIntervalDescription')} Time to wait between retry
attempts.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -921,7 +944,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.autoStart`} name={`tunnelConnections.${index}.autoStart`}
render={({field}) => ( render={({field}) => (
<FormItem className="col-span-4"> <FormItem className="col-span-4">
<FormLabel>{t('hosts.autoStartContainer')}</FormLabel> <FormLabel>Auto Start on Container
Launch</FormLabel>
<FormControl> <FormControl>
<Switch <Switch
checked={field.value} checked={field.value}
@@ -929,7 +953,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('hosts.autoStartDesc')} Automatically start this tunnel
when the container launches.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -951,7 +976,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
}]); }]);
}} }}
> >
{t('hosts.addConnection')} Add Tunnel Connection
</Button> </Button>
</div> </div>
</FormControl> </FormControl>
@@ -969,7 +994,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="enableFileManager" name="enableFileManager"
render={({field}) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>{t('hosts.enableFileManager')}</FormLabel> <FormLabel>Enable File Manager</FormLabel>
<FormControl> <FormControl>
<Switch <Switch
checked={field.value} checked={field.value}
@@ -977,7 +1002,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('hosts.enableFileManagerDesc')} Enable/disable host visibility in File Manager tab.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -990,11 +1015,12 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="defaultPath" name="defaultPath"
render={({field}) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>{t('hosts.defaultPath')}</FormLabel> <FormLabel>Default Path</FormLabel>
<FormControl> <FormControl>
<Input placeholder={t('placeholders.homePath')} {...field} /> <Input placeholder="/home" {...field} />
</FormControl> </FormControl>
<FormDescription>{t('hosts.defaultPathDesc')}</FormDescription> <FormDescription>Set default directory shown when connected via
File Manager</FormDescription>
</FormItem> </FormItem>
)} )}
/> />
@@ -1013,7 +1039,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
transform: 'translateY(8px)' transform: 'translateY(8px)'
}} }}
> >
{editingHost ? t('hosts.updateHost') : t('hosts.addHost')} {editingHost ? "Update Host" : "Add Host"}
</Button> </Button>
</footer> </footer>
</form> </form>

View File

@@ -7,7 +7,6 @@ import {Input} from "@/components/ui/input";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
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 { import {
Edit, Edit,
Trash2, Trash2,
@@ -48,7 +47,6 @@ interface SSHManagerHostViewerProps {
} }
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -66,20 +64,20 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
setHosts(data); setHosts(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(t('hosts.failedToLoadHosts')); setError('Failed to load hosts');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleDelete = async (hostId: number, hostName: string) => { 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 { try {
await deleteSSHHost(hostId); await deleteSSHHost(hostId);
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (err) { } 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); const data = JSON.parse(text);
if (!Array.isArray(data.hosts) && !Array.isArray(data)) { 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; const hostsArray = Array.isArray(data.hosts) ? data.hosts : data;
if (hostsArray.length === 0) { if (hostsArray.length === 0) {
throw new Error(t('hosts.noHostsInJson')); throw new Error('No hosts found in JSON file');
} }
if (hostsArray.length > 100) { if (hostsArray.length > 100) {
throw new Error(t('hosts.maxHostsAllowed')); throw new Error('Maximum 100 hosts allowed per import');
} }
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') : '')); alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
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')}`); alert(`Import failed: ${result.errors.join('\n')}`);
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson'); const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file';
alert(`${t('hosts.importError')}: ${errorMessage}`); alert(`Import error: ${errorMessage}`);
} finally { } finally {
setImporting(false); setImporting(false);
event.target.value = ''; event.target.value = '';
@@ -165,7 +163,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const grouped: { [key: string]: SSHHost[] } = {}; const grouped: { [key: string]: SSHHost[] } = {};
filteredAndSortedHosts.forEach(host => { filteredAndSortedHosts.forEach(host => {
const folder = host.folder || t('hosts.uncategorized'); const folder = host.folder || 'Uncategorized';
if (!grouped[folder]) { if (!grouped[folder]) {
grouped[folder] = []; grouped[folder] = [];
} }
@@ -173,9 +171,8 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
}); });
const sortedFolders = Object.keys(grouped).sort((a, b) => { const sortedFolders = Object.keys(grouped).sort((a, b) => {
const uncategorized = t('hosts.uncategorized'); if (a === 'Uncategorized') return -1;
if (a === uncategorized) return -1; if (b === 'Uncategorized') return 1;
if (b === uncategorized) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
@@ -192,7 +189,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">{t('hosts.loadingHosts')}</p> <p className="text-muted-foreground">Loading hosts...</p>
</div> </div>
</div> </div>
); );
@@ -204,7 +201,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="text-center"> <div className="text-center">
<p className="text-red-500 mb-4">{error}</p> <p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchHosts} variant="outline"> <Button onClick={fetchHosts} variant="outline">
{t('hosts.retry')} Retry
</Button> </Button>
</div> </div>
</div> </div>
@@ -216,9 +213,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-center"> <div className="text-center">
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/> <Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3> <h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{t('hosts.noHostsMessage')} You haven't added any SSH hosts yet. Click "Add Host" to get started.
</p> </p>
</div> </div>
</div> </div>
@@ -229,9 +226,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div> <div>
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2> <h2 className="text-xl font-semibold">SSH Hosts</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('hosts.hostsCount', { count: filteredAndSortedHosts.length })} {filteredAndSortedHosts.length} hosts
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -245,15 +242,15 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
onClick={() => document.getElementById('json-import-input')?.click()} onClick={() => document.getElementById('json-import-input')?.click()}
disabled={importing} disabled={importing}
> >
{importing ? t('hosts.importing') : t('hosts.importJson')} {importing ? 'Importing...' : 'Import JSON'}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" <TooltipContent side="bottom"
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg"> className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
<div className="space-y-2"> <div className="space-y-2">
<p className="font-semibold text-sm">{t('hosts.importJsonTitle')}</p> <p className="font-semibold text-sm">Import SSH Hosts from JSON</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('hosts.importJsonDesc')} Upload a JSON file to bulk import multiple SSH hosts (max 100).
</p> </p>
</div> </div>
</TooltipContent> </TooltipContent>
@@ -321,7 +318,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}} }}
> >
{t('hosts.downloadSample')} Download Sample
</Button> </Button>
<Button <Button
@@ -331,13 +328,13 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
window.open('https://docs.termix.site/json-import', '_blank'); window.open('https://docs.termix.site/json-import', '_blank');
}} }}
> >
{t('hosts.formatGuide')} Format Guide
</Button> </Button>
<div className="w-px h-6 bg-border mx-2"/> <div className="w-px h-6 bg-border mx-2"/>
<Button onClick={fetchHosts} variant="outline" size="sm"> <Button onClick={fetchHosts} variant="outline" size="sm">
{t('hosts.refresh')} Refresh
</Button> </Button>
</div> </div>
</div> </div>
@@ -353,7 +350,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="relative mb-3"> <div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input <Input
placeholder={t('placeholders.searchHosts')} placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10"

View File

@@ -72,13 +72,20 @@ export function Homepage({
} }
}, [isAuthenticated]); }, [isAuthenticated]);
const topOffset = isTopbarOpen ? 66 : 0;
const topPadding = isTopbarOpen ? 66 : 0;
return ( return (
<div <div
className={`w-full min-h-svh relative transition-[padding-top] duration-200 ease-linear ${ className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out"
isTopbarOpen ? 'pt-[66px]' : 'pt-2' style={{ paddingTop: `${topPadding}px` }}>
}`}>
{!loggedIn ? ( {!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 <HomepageAuth
setLoggedIn={setLoggedIn} setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin} setIsAdmin={setIsAdmin}
@@ -92,8 +99,13 @@ export function Homepage({
/> />
</div> </div>
) : ( ) : (
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center"> <div
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]"> 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="flex flex-col items-center gap-6 w-[400px]">
<div <div
className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg"> className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg">

View File

@@ -6,7 +6,6 @@ import {Label} from "@/components/ui/label.tsx";
import {Input} from "@/components/ui/input.tsx"; 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";
interface PasswordResetProps { interface PasswordResetProps {
userInfo: { userInfo: {
@@ -18,7 +17,6 @@ interface PasswordResetProps {
} }
export function PasswordReset({userInfo}: PasswordResetProps) { export function PasswordReset({userInfo}: PasswordResetProps) {
const {t} = useTranslation();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate"); const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
@@ -37,7 +35,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setResetStep("verify"); setResetStep("verify");
setError(null); setError(null);
} catch (err: any) { } 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 { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -62,7 +60,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setResetStep("newPassword"); setResetStep("newPassword");
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error || t('errors.failedVerifyCode')); setError(err?.response?.data?.error || "Failed to verify reset code");
} finally { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -73,13 +71,13 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setResetLoading(true); setResetLoading(true);
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError(t('errors.passwordMismatch')); setError("Passwords do not match");
setResetLoading(false); setResetLoading(false);
return; return;
} }
if (newPassword.length < 6) { if (newPassword.length < 6) {
setError(t('errors.weakPassword')); setError("Password must be at least 6 characters long");
setResetLoading(false); setResetLoading(false);
return; return;
} }
@@ -96,7 +94,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setResetSuccess(true); setResetSuccess(true);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error || t('errors.failedCompleteReset')); setError(err?.response?.data?.error || "Failed to complete password reset");
} finally { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -117,7 +115,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
Password Password
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t('profile.changePassword')} Change your account password
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -131,7 +129,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
disabled={resetLoading || !userInfo.username.trim()} disabled={resetLoading || !userInfo.username.trim()}
onClick={handleInitiatePasswordReset} onClick={handleInitiatePasswordReset}
> >
{resetLoading ? Spinner : t('auth.sendResetCode')} {resetLoading ? Spinner : "Send Reset Code"}
</Button> </Button>
</div> </div>
</> </>
@@ -140,11 +138,12 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
{resetStep === "verify" && ( {resetStep === "verify" && (
<> <>
<div className="text-center text-muted-foreground mb-4"> <div className="text-center text-muted-foreground mb-4">
<p>{t('auth.enterResetCode')}: <strong>{userInfo.username}</strong></p> <p>Enter the 6-digit code from the docker container logs for
user: <strong>{userInfo.username}</strong></p>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="reset-code">{t('auth.resetCode')}</Label> <Label htmlFor="reset-code">Reset Code</Label>
<Input <Input
id="reset-code" id="reset-code"
type="text" type="text"
@@ -163,7 +162,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
disabled={resetLoading || resetCode.length !== 6} disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode} onClick={handleVerifyResetCode}
> >
{resetLoading ? Spinner : t('auth.verifyCode')} {resetLoading ? Spinner : "Verify Code"}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -175,7 +174,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setResetCode(""); setResetCode("");
}} }}
> >
{t('common.back')} Back
</Button> </Button>
</div> </div>
</> </>
@@ -184,9 +183,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
{resetSuccess && ( {resetSuccess && (
<> <>
<Alert className=""> <Alert className="">
<AlertTitle>{t('auth.passwordResetSuccess')}</AlertTitle> <AlertTitle>Success!</AlertTitle>
<AlertDescription> <AlertDescription>
{t('auth.passwordResetSuccessDesc')} Your password has been successfully reset! You can now log in
with your new password.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</> </>
@@ -195,11 +195,12 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
{resetStep === "newPassword" && !resetSuccess && ( {resetStep === "newPassword" && !resetSuccess && (
<> <>
<div className="text-center text-muted-foreground mb-4"> <div className="text-center text-muted-foreground mb-4">
<p>{t('auth.enterNewPassword')}: <strong>{userInfo.username}</strong></p> <p>Enter your new password for
user: <strong>{userInfo.username}</strong></p>
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="new-password">{t('auth.newPassword')}</Label> <Label htmlFor="new-password">New Password</Label>
<Input <Input
id="new-password" id="new-password"
type="password" type="password"
@@ -212,7 +213,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label> <Label htmlFor="confirm-password">Confirm Password</Label>
<Input <Input
id="confirm-password" id="confirm-password"
type="password" type="password"
@@ -230,7 +231,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
disabled={resetLoading || !newPassword || !confirmPassword} disabled={resetLoading || !newPassword || !confirmPassword}
onClick={handleCompletePasswordReset} onClick={handleCompletePasswordReset}
> >
{resetLoading ? Spinner : t('auth.resetPassword')} {resetLoading ? Spinner : "Reset Password"}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -243,14 +244,14 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setConfirmPassword(""); setConfirmPassword("");
}} }}
> >
{t('common.back')} Back
</Button> </Button>
</div> </div>
</> </>
)} )}
{error && ( {error && (
<Alert variant="destructive" className="mt-4"> <Alert variant="destructive" className="mt-4">
<AlertTitle>{t('common.error')}</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}