feat: Add many terminal customizations
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
updateSSHHost,
|
||||
enableAutoStart,
|
||||
disableAutoStart,
|
||||
getSnippets,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CredentialSelector } from "@/ui/Desktop/Apps/Credentials/CredentialSelector.tsx";
|
||||
@@ -41,6 +42,31 @@ import { EditorView } from "@codemirror/view";
|
||||
import type { StatsConfig } from "@/types/stats-widgets";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { Slider } from "@/components/ui/slider.tsx";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
TERMINAL_FONTS,
|
||||
CURSOR_STYLES,
|
||||
BELL_STYLES,
|
||||
FAST_SCROLL_MODIFIERS,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
} from "@/constants/terminal-themes";
|
||||
import { TerminalPreview } from "@/ui/Desktop/Apps/Terminal/TerminalPreview.tsx";
|
||||
import type { TerminalConfig } from "@/types";
|
||||
import { Plus, X } from "lucide-react";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -69,6 +95,7 @@ interface SSHHost {
|
||||
autoStart: boolean;
|
||||
}>;
|
||||
statsConfig?: StatsConfig;
|
||||
terminalConfig?: TerminalConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
credentialId?: number;
|
||||
@@ -89,6 +116,9 @@ export function HostManagerEditor({
|
||||
const [credentials, setCredentials] = useState<
|
||||
Array<{ id: number; username: string; authType: string }>
|
||||
>([]);
|
||||
const [snippets, setSnippets] = useState<
|
||||
Array<{ id: number; name: string; content: string }>
|
||||
>([]);
|
||||
|
||||
const [authTab, setAuthTab] = useState<
|
||||
"password" | "key" | "credential" | "none"
|
||||
@@ -103,11 +133,13 @@ export function HostManagerEditor({
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [hostsData, credentialsData] = await Promise.all([
|
||||
const [hostsData, credentialsData, snippetsData] = await Promise.all([
|
||||
getSSHHosts(),
|
||||
getCredentials(),
|
||||
getSnippets(),
|
||||
]);
|
||||
setCredentials(credentialsData);
|
||||
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
|
||||
|
||||
const uniqueFolders = [
|
||||
...new Set(
|
||||
@@ -239,6 +271,34 @@ export function HostManagerEditor({
|
||||
"system",
|
||||
],
|
||||
}),
|
||||
terminalConfig: z
|
||||
.object({
|
||||
cursorBlink: z.boolean(),
|
||||
cursorStyle: z.enum(["block", "underline", "bar"]),
|
||||
fontSize: z.number().min(8).max(24),
|
||||
fontFamily: z.string(),
|
||||
letterSpacing: z.number().min(-2).max(5),
|
||||
lineHeight: z.number().min(1.0).max(2.0),
|
||||
theme: z.string(),
|
||||
scrollback: z.number().min(1000).max(50000),
|
||||
bellStyle: z.enum(["none", "sound", "visual", "both"]),
|
||||
rightClickSelectsWord: z.boolean(),
|
||||
fastScrollModifier: z.enum(["alt", "ctrl", "shift"]),
|
||||
fastScrollSensitivity: z.number().min(1).max(10),
|
||||
minimumContrastRatio: z.number().min(1).max(21),
|
||||
backspaceMode: z.enum(["normal", "control-h"]),
|
||||
agentForwarding: z.boolean(),
|
||||
environmentVariables: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
startupSnippetId: z.number().nullable(),
|
||||
autoMosh: z.boolean(),
|
||||
moshCommand: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "none") {
|
||||
@@ -327,6 +387,7 @@ export function HostManagerEditor({
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -386,6 +447,7 @@ export function HostManagerEditor({
|
||||
defaultPath: cleanedHost.defaultPath || "/",
|
||||
tunnelConnections: cleanedHost.tunnelConnections || [],
|
||||
statsConfig: cleanedHost.statsConfig || DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
||||
};
|
||||
|
||||
if (defaultAuthType === "password") {
|
||||
@@ -432,6 +494,7 @@ export function HostManagerEditor({
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
};
|
||||
|
||||
form.reset(defaultFormData);
|
||||
@@ -471,6 +534,7 @@ export function HostManagerEditor({
|
||||
defaultPath: data.defaultPath || "/",
|
||||
tunnelConnections: data.tunnelConnections || [],
|
||||
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
||||
};
|
||||
|
||||
submitData.credentialId = null;
|
||||
@@ -1178,7 +1242,7 @@ export function HostManagerEditor({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
<TabsContent value="terminal">
|
||||
<TabsContent value="terminal" className="space-y-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableTerminal"
|
||||
@@ -1197,6 +1261,615 @@ export function HostManagerEditor({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold mt-7">
|
||||
Terminal Customization
|
||||
</h1>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="appearance">
|
||||
<AccordionTrigger>Appearance</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Theme Preview
|
||||
</label>
|
||||
<TerminalPreview
|
||||
theme={form.watch("terminalConfig.theme")}
|
||||
fontSize={form.watch("terminalConfig.fontSize")}
|
||||
fontFamily={form.watch("terminalConfig.fontFamily")}
|
||||
cursorStyle={form.watch("terminalConfig.cursorStyle")}
|
||||
cursorBlink={form.watch("terminalConfig.cursorBlink")}
|
||||
letterSpacing={form.watch(
|
||||
"terminalConfig.letterSpacing",
|
||||
)}
|
||||
lineHeight={form.watch("terminalConfig.lineHeight")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(TERMINAL_THEMES).map(
|
||||
([key, theme]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose a color theme for the terminal
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Font Family */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fontFamily"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Font Family</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select font" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{TERMINAL_FONTS.map((font) => (
|
||||
<SelectItem
|
||||
key={font.value}
|
||||
value={font.value}
|
||||
>
|
||||
{font.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the font to use in the terminal
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Font Size */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fontSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Font Size: {field.value}px</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={8}
|
||||
max={24}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) =>
|
||||
field.onChange(value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Adjust the terminal font size
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Letter Spacing */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.letterSpacing"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Letter Spacing: {field.value}px
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) =>
|
||||
field.onChange(value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Adjust spacing between characters
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Line Height */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.lineHeight"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Line Height: {field.value}</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={1}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) =>
|
||||
field.onChange(value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Adjust spacing between lines
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Cursor Style */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.cursorStyle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cursor Style</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select cursor style" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="block">Block</SelectItem>
|
||||
<SelectItem value="underline">
|
||||
Underline
|
||||
</SelectItem>
|
||||
<SelectItem value="bar">Bar</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the cursor appearance
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Cursor Blink */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.cursorBlink"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Cursor Blink</FormLabel>
|
||||
<FormDescription>
|
||||
Enable cursor blinking animation
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Behavior Settings */}
|
||||
<AccordionItem value="behavior">
|
||||
<AccordionTrigger>Behavior</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
{/* Scrollback Buffer */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.scrollback"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Scrollback Buffer: {field.value} lines
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) =>
|
||||
field.onChange(value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Number of lines to keep in scrollback history
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Bell Style */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.bellStyle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bell Style</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select bell style" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="sound">Sound</SelectItem>
|
||||
<SelectItem value="visual">Visual</SelectItem>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
How to handle terminal bell (BEL character)
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Right Click Selects Word */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.rightClickSelectsWord"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Right Click Selects Word</FormLabel>
|
||||
<FormDescription>
|
||||
Right-clicking selects the word under cursor
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Fast Scroll Modifier */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fastScrollModifier"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Fast Scroll Modifier</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select modifier" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="alt">Alt</SelectItem>
|
||||
<SelectItem value="ctrl">Ctrl</SelectItem>
|
||||
<SelectItem value="shift">Shift</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Modifier key for fast scrolling
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Fast Scroll Sensitivity */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fastScrollSensitivity"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Fast Scroll Sensitivity: {field.value}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) =>
|
||||
field.onChange(value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Scroll speed multiplier when modifier is held
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Minimum Contrast Ratio */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.minimumContrastRatio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Minimum Contrast Ratio: {field.value}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={1}
|
||||
max={21}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) =>
|
||||
field.onChange(value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Automatically adjust colors for better readability
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>Advanced</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
{/* Agent Forwarding */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.agentForwarding"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>SSH Agent Forwarding</FormLabel>
|
||||
<FormDescription>
|
||||
Forward SSH authentication agent to remote host
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Backspace Mode */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.backspaceMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Backspace Mode</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select backspace mode" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">
|
||||
Normal (DEL)
|
||||
</SelectItem>
|
||||
<SelectItem value="control-h">
|
||||
Control-H (^H)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Backspace key behavior for compatibility
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Startup Snippet */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.startupSnippetId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Startup Snippet</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(
|
||||
value === "none" ? null : parseInt(value),
|
||||
)
|
||||
}
|
||||
value={field.value?.toString() || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select snippet" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{snippets.map((snippet) => (
|
||||
<SelectItem
|
||||
key={snippet.id}
|
||||
value={snippet.id.toString()}
|
||||
>
|
||||
{snippet.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Execute a snippet when the terminal connects
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Auto MOSH */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.autoMosh"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Auto-MOSH</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically run MOSH command on connect
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* MOSH Command */}
|
||||
{form.watch("terminalConfig.autoMosh") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.moshCommand"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>MOSH Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="mosh user@server"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The MOSH command to execute
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Environment Variables
|
||||
</label>
|
||||
<FormDescription>
|
||||
Set custom environment variables for the terminal
|
||||
session
|
||||
</FormDescription>
|
||||
{form
|
||||
.watch("terminalConfig.environmentVariables")
|
||||
?.map((_, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`terminalConfig.environmentVariables.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Variable name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`terminalConfig.environmentVariables.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const current = form.getValues(
|
||||
"terminalConfig.environmentVariables",
|
||||
);
|
||||
form.setValue(
|
||||
"terminalConfig.environmentVariables",
|
||||
current.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const current =
|
||||
form.getValues(
|
||||
"terminalConfig.environmentVariables",
|
||||
) || [];
|
||||
form.setValue(
|
||||
"terminalConfig.environmentVariables",
|
||||
[...current, { key: "", value: "" }],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
<TabsContent value="tunnel">
|
||||
<FormField
|
||||
|
||||
@@ -12,8 +12,19 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { getCookie, isElectron, logActivity } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
getCookie,
|
||||
isElectron,
|
||||
logActivity,
|
||||
getSnippets,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ui/components/TOTPDialog";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
} from "@/constants/terminal-themes";
|
||||
import type { TerminalConfig } from "@/types";
|
||||
|
||||
interface HostConfig {
|
||||
id?: number;
|
||||
@@ -26,6 +37,7 @@ interface HostConfig {
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
terminalConfig?: TerminalConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -72,6 +84,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
|
||||
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
||||
const themeColors =
|
||||
TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
|
||||
const backgroundColor = themeColors.background;
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -84,6 +101,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const [, setIsAuthenticated] = useState(false);
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
@@ -172,12 +190,13 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (webSocketRef.current && code) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "totp_response",
|
||||
type: isPasswordPrompt ? "password_response" : "totp_response",
|
||||
data: { code },
|
||||
}),
|
||||
);
|
||||
setTotpRequired(false);
|
||||
setTotpPrompt("");
|
||||
setIsPasswordPrompt(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,6 +519,65 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
// Log activity for recent connections
|
||||
logTerminalActivity();
|
||||
|
||||
// Execute post-connection actions
|
||||
setTimeout(async () => {
|
||||
// Merge default config with host-specific config
|
||||
const terminalConfig = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
// Set environment variables
|
||||
if (
|
||||
terminalConfig.environmentVariables &&
|
||||
terminalConfig.environmentVariables.length > 0
|
||||
) {
|
||||
for (const envVar of terminalConfig.environmentVariables) {
|
||||
if (envVar.key && envVar.value && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data: `export ${envVar.key}="${envVar.value}"\n`,
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute startup snippet
|
||||
if (terminalConfig.startupSnippetId) {
|
||||
try {
|
||||
const snippets = await getSnippets();
|
||||
const snippet = snippets.find(
|
||||
(s: { id: number }) =>
|
||||
s.id === terminalConfig.startupSnippetId,
|
||||
);
|
||||
if (snippet && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data: snippet.content + "\n",
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to execute startup snippet:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute MOSH command
|
||||
if (terminalConfig.autoMosh && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data: terminalConfig.moshCommand + "\n",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
setIsConnected(false);
|
||||
@@ -513,6 +591,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
} else if (msg.type === "totp_required") {
|
||||
setTotpRequired(true);
|
||||
setTotpPrompt(msg.prompt || "Verification code:");
|
||||
setIsPasswordPrompt(false);
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "password_required") {
|
||||
setTotpRequired(true);
|
||||
setTotpPrompt(msg.prompt || "Password:");
|
||||
setIsPasswordPrompt(true);
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
@@ -606,27 +693,66 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
|
||||
// Merge default config with host-specific config
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
// Get theme colors
|
||||
const themeColors =
|
||||
TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
|
||||
|
||||
// Get font family with fallback
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
(f) => f.value === config.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
scrollback: 10000,
|
||||
fontSize: 14,
|
||||
fontFamily:
|
||||
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
cursorBlink: config.cursorBlink,
|
||||
cursorStyle: config.cursorStyle,
|
||||
scrollback: config.scrollback,
|
||||
fontSize: config.fontSize,
|
||||
fontFamily,
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: false,
|
||||
fastScrollModifier: "alt",
|
||||
fastScrollSensitivity: 5,
|
||||
rightClickSelectsWord: config.rightClickSelectsWord,
|
||||
fastScrollModifier: config.fastScrollModifier,
|
||||
fastScrollSensitivity: config.fastScrollSensitivity,
|
||||
allowProposedApi: true,
|
||||
minimumContrastRatio: 1,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.2,
|
||||
minimumContrastRatio: config.minimumContrastRatio,
|
||||
letterSpacing: config.letterSpacing,
|
||||
lineHeight: config.lineHeight,
|
||||
bellStyle: config.bellStyle as "none" | "sound",
|
||||
|
||||
theme: { background: "#18181b", foreground: "#f7f7f7" },
|
||||
theme: {
|
||||
background: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
cursor: themeColors.cursor,
|
||||
cursorAccent: themeColors.cursorAccent,
|
||||
selectionBackground: themeColors.selectionBackground,
|
||||
selectionForeground: themeColors.selectionForeground,
|
||||
black: themeColors.black,
|
||||
red: themeColors.red,
|
||||
green: themeColors.green,
|
||||
yellow: themeColors.yellow,
|
||||
blue: themeColors.blue,
|
||||
magenta: themeColors.magenta,
|
||||
cyan: themeColors.cyan,
|
||||
white: themeColors.white,
|
||||
brightBlack: themeColors.brightBlack,
|
||||
brightRed: themeColors.brightRed,
|
||||
brightGreen: themeColors.brightGreen,
|
||||
brightYellow: themeColors.brightYellow,
|
||||
brightBlue: themeColors.brightBlue,
|
||||
brightMagenta: themeColors.brightMagenta,
|
||||
brightCyan: themeColors.brightCyan,
|
||||
brightWhite: themeColors.brightWhite,
|
||||
},
|
||||
};
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
@@ -671,6 +797,24 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
||||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
// Handle backspace mode (Control-H)
|
||||
if (
|
||||
config.backspaceMode === "control-h" &&
|
||||
e.key === "Backspace" &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: "\x08" }),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isMacOS) return;
|
||||
|
||||
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
@@ -739,7 +883,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal]);
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !hostConfig || !visible) return;
|
||||
@@ -813,7 +957,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}, [splitScreen, isVisible, terminal]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
<div className="h-full w-full relative" style={{ backgroundColor }}>
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"}`}
|
||||
@@ -829,10 +973,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">{t("terminal.connecting")}</span>
|
||||
@@ -846,6 +994,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
/* Import popular terminal fonts from Google Fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||
|
||||
@@ -722,364 +722,412 @@ export function Auth({
|
||||
|
||||
{!loggedIn && !authLoading && !totpRequired && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
{passwordLoginAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
{t("common.login")}
|
||||
</button>
|
||||
)}
|
||||
{passwordLoginAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
{t("common.register")}
|
||||
</button>
|
||||
)}
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login" || tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("auth.external")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login"
|
||||
? t("auth.loginTitle")
|
||||
: tab === "signup"
|
||||
? t("auth.registerTitle")
|
||||
: tab === "external"
|
||||
? t("auth.loginWithExternal")
|
||||
: t("auth.forgotPassword")}
|
||||
</h2>
|
||||
</div>
|
||||
{(() => {
|
||||
// Check if any authentication method is available
|
||||
const hasLogin = passwordLoginAllowed && !firstUser;
|
||||
const hasSignup =
|
||||
(passwordLoginAllowed || firstUser) && registrationAllowed;
|
||||
const hasOIDC = oidcConfigured;
|
||||
const hasAnyAuth = hasLogin || hasSignup || hasOIDC;
|
||||
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||
</div>
|
||||
{(() => {
|
||||
if (isElectron()) {
|
||||
return (
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg border">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.externalNotSupportedInElectron")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.resetCodeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
if (!hasAnyAuth) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{t("auth.authenticationDisabled")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("auth.authenticationDisabledDesc")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
{passwordLoginAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
{t("common.login")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterResetCode")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">
|
||||
{t("auth.resetCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.verifyCodeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
{(passwordLoginAllowed || firstUser) &&
|
||||
registrationAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.register")}
|
||||
</button>
|
||||
)}
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login" || tab === "signup")
|
||||
clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("auth.external")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login"
|
||||
? t("auth.loginTitle")
|
||||
: tab === "signup"
|
||||
? t("auth.registerTitle")
|
||||
: tab === "external"
|
||||
? t("auth.loginWithExternal")
|
||||
: t("auth.forgotPassword")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterNewPassword")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-p assword">
|
||||
{t("auth.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("auth.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || !newPassword || !confirmPassword
|
||||
{(() => {
|
||||
if (isElectron()) {
|
||||
return (
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg border">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.externalNotSupportedInElectron")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading
|
||||
? Spinner
|
||||
: t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={(e) => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
>
|
||||
{loading
|
||||
? Spinner
|
||||
: tab === "login"
|
||||
? t("common.login")
|
||||
: t("auth.signUp")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || loggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.resetCodeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) =>
|
||||
setLocalUsername(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-dark-border space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
Server
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{currentServerUrl}
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterResetCode")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">
|
||||
{t("auth.resetCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(
|
||||
e.target.value.replace(/\D/g, ""),
|
||||
)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || resetCode.length !== 6
|
||||
}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.verifyCodeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterNewPassword")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-p assword">
|
||||
{t("auth.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) =>
|
||||
setNewPassword(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("auth.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
setConfirmPassword(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading ||
|
||||
!newPassword ||
|
||||
!confirmPassword
|
||||
}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={(e) =>
|
||||
setSignupConfirmPassword(e.target.value)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
>
|
||||
{loading
|
||||
? Spinner
|
||||
: tab === "login"
|
||||
? t("common.login")
|
||||
: t("auth.signUp")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || loggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-dark-border space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
Server
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{currentServerUrl}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,10 @@ import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
} from "@/constants/terminal-themes";
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
@@ -24,7 +28,7 @@ interface TabData {
|
||||
refresh?: () => void;
|
||||
};
|
||||
};
|
||||
hostConfig?: unknown;
|
||||
hostConfig?: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -258,9 +262,24 @@ export function AppView({
|
||||
|
||||
const effectiveVisible = isVisible && ready;
|
||||
|
||||
const isTerminal = t.type === "terminal";
|
||||
const terminalConfig = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(t.hostConfig as any)?.terminalConfig,
|
||||
};
|
||||
const themeColors =
|
||||
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
|
||||
TERMINAL_THEMES.termix.colors;
|
||||
const backgroundColor = themeColors.background;
|
||||
|
||||
return (
|
||||
<div key={t.id} style={finalStyle}>
|
||||
<div className="absolute inset-0 rounded-md bg-dark-bg">
|
||||
<div
|
||||
className="absolute inset-0 rounded-md overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: isTerminal ? backgroundColor : "#18181b",
|
||||
}}
|
||||
>
|
||||
{t.type === "terminal" ? (
|
||||
<Terminal
|
||||
ref={t.terminalRef}
|
||||
@@ -605,21 +624,37 @@ export function AppView({
|
||||
|
||||
const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab);
|
||||
const isFileManager = currentTabData?.type === "file_manager";
|
||||
const isTerminal = currentTabData?.type === "terminal";
|
||||
const isSplitScreen = allSplitScreenTab.length > 0;
|
||||
|
||||
// Get terminal background color for the current tab
|
||||
const terminalConfig = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(currentTabData?.hostConfig as any)?.terminalConfig,
|
||||
};
|
||||
const themeColors =
|
||||
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
|
||||
TERMINAL_THEMES.termix.colors;
|
||||
const terminalBackgroundColor = themeColors.background;
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
// Determine background color based on current tab type
|
||||
let containerBackground = "var(--color-dark-bg)";
|
||||
if (isFileManager && !isSplitScreen) {
|
||||
containerBackground = "var(--color-dark-bg-darkest)";
|
||||
} else if (isTerminal) {
|
||||
containerBackground = terminalBackgroundColor;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="border-2 border-dark-border rounded-lg overflow-hidden overflow-x-hidden relative"
|
||||
style={{
|
||||
background:
|
||||
isFileManager && !isSplitScreen
|
||||
? "var(--color-dark-bg-darkest)"
|
||||
: "var(--color-dark-bg)",
|
||||
background: containerBackground,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
|
||||
Reference in New Issue
Block a user