Credential manager + bug fixes #191

Merged
LukeGus merged 18 commits from zac-dev into main 2025-09-08 02:23:48 +00:00
9 changed files with 918 additions and 1030 deletions
Showing only changes of commit e5939dadbe - Show all commits
+32
View File
@@ -11,6 +11,7 @@
"updateCredential": "Update Credential", "updateCredential": "Update Credential",
"credentialName": "Credential Name", "credentialName": "Credential Name",
"credentialDescription": "Description", "credentialDescription": "Description",
"username": "Username",
"searchCredentials": "Search credentials...", "searchCredentials": "Search credentials...",
"selectFolder": "Select Folder", "selectFolder": "Select Folder",
"selectAuthType": "Select Auth Type", "selectAuthType": "Select Auth Type",
@@ -33,6 +34,32 @@
"failedToSaveCredential": "Failed to save credential", "failedToSaveCredential": "Failed to save credential",
"failedToFetchCredentialDetails": "Failed to fetch credential details", "failedToFetchCredentialDetails": "Failed to fetch credential details",
"failedToFetchHostsUsing": "Failed to fetch hosts using this credential", "failedToFetchHostsUsing": "Failed to fetch hosts using this credential",
"loadingCredentials": "Loading credentials...",
"retry": "Retry",
"noCredentials": "No Credentials",
"noCredentialsMessage": "Start by creating your first SSH credential",
"sshCredentials": "SSH Credentials",
"credentialsCount": "{{count}} credentials",
"refresh": "Refresh",
"passwordRequired": "Password is required",
"sshKeyRequired": "SSH key is required",
"credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully",
"general": "General",
"description": "Description",
"folder": "Folder",
"tags": "Tags",
"addTagsSpaceToAdd": "Add tags (press space to add)",
"password": "Password",
"key": "Key",
"sshPrivateKey": "SSH Private Key",
"upload": "Upload",
"updateKey": "Update Key",
"keyPassword": "Key Password (optional)",
"keyType": "Key Type",
"keyTypeRSA": "RSA",
"keyTypeECDSA": "ECDSA",
"keyTypeEd25519": "Ed25519",
"updateCredential": "Update Credential",
"basicInfo": "Basic Info", "basicInfo": "Basic Info",
"authentication": "Authentication", "authentication": "Authentication",
"organization": "Organization", "organization": "Organization",
@@ -236,6 +263,7 @@
}, },
"admin": { "admin": {
"title": "Admin Settings", "title": "Admin Settings",
"oidc": "OIDC",
"users": "Users", "users": "Users",
"userManagement": "User Management", "userManagement": "User Management",
"makeAdmin": "Make Admin", "makeAdmin": "Make Admin",
@@ -770,6 +798,9 @@
"folder": "folder", "folder": "folder",
"password": "password", "password": "password",
"keyPassword": "key password", "keyPassword": "key password",
"credentialName": "My SSH Server",
"description": "SSH credential description",
"searchCredentials": "Search credentials by name, username, or tags...",
"sshConfig": "endpoint ssh configuration", "sshConfig": "endpoint ssh configuration",
"homePath": "/home", "homePath": "/home",
"clientId": "your-client-id", "clientId": "your-client-id",
@@ -780,6 +811,7 @@
"userIdField": "sub", "userIdField": "sub",
"usernameField": "name", "usernameField": "name",
"scopes": "openid email profile", "scopes": "openid email profile",
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
"enterUsername": "Enter username to make admin", "enterUsername": "Enter username to make admin",
"searchHosts": "Search hosts by name, username, IP, folder, tags...", "searchHosts": "Search hosts by name, username, IP, folder, tags...",
"enterPassword": "Enter your password", "enterPassword": "Enter your password",
+32
View File
@@ -11,6 +11,7 @@
"updateCredential": "更新凭据", "updateCredential": "更新凭据",
"credentialName": "凭据名称", "credentialName": "凭据名称",
"credentialDescription": "描述", "credentialDescription": "描述",
"username": "用户名",
"searchCredentials": "搜索凭据...", "searchCredentials": "搜索凭据...",
"selectFolder": "选择文件夹", "selectFolder": "选择文件夹",
"selectAuthType": "选择认证类型", "selectAuthType": "选择认证类型",
@@ -33,6 +34,32 @@
"failedToSaveCredential": "保存凭据失败", "failedToSaveCredential": "保存凭据失败",
"failedToFetchCredentialDetails": "获取凭据详情失败", "failedToFetchCredentialDetails": "获取凭据详情失败",
"failedToFetchHostsUsing": "获取使用此凭据的主机失败", "failedToFetchHostsUsing": "获取使用此凭据的主机失败",
"loadingCredentials": "正在加载凭据...",
"retry": "重试",
"noCredentials": "暂无凭据",
"noCredentialsMessage": "开始创建您的第一个SSH凭据",
"sshCredentials": "SSH凭据",
"credentialsCount": "{{count}} 个凭据",
"refresh": "刷新",
"passwordRequired": "密码为必填项",
"sshKeyRequired": "SSH密钥为必填项",
"credentialAddedSuccessfully": "凭据「{{name}}」添加成功",
"general": "常规",
"description": "描述",
"folder": "文件夹",
"tags": "标签",
"addTagsSpaceToAdd": "添加标签(按空格键添加)",
"password": "密码",
"key": "密钥",
"sshPrivateKey": "SSH私钥",
"upload": "上传",
"updateKey": "更新密钥",
"keyPassword": "密钥密码(可选)",
"keyType": "密钥类型",
"keyTypeRSA": "RSA",
"keyTypeECDSA": "ECDSA",
"keyTypeEd25519": "Ed25519",
"updateCredential": "更新凭据",
"basicInfo": "基本信息", "basicInfo": "基本信息",
"authentication": "认证方式", "authentication": "认证方式",
"organization": "组织管理", "organization": "组织管理",
@@ -236,6 +263,7 @@
}, },
"admin": { "admin": {
"title": "管理员设置", "title": "管理员设置",
"oidc": "OIDC",
"users": "用户", "users": "用户",
"userManagement": "用户管理", "userManagement": "用户管理",
"makeAdmin": "设为管理员", "makeAdmin": "设为管理员",
@@ -807,6 +835,9 @@
"hostname": "主机名", "hostname": "主机名",
"folder": "文件夹", "folder": "文件夹",
"password": "密码", "password": "密码",
"credentialName": "我的SSH服务器",
"description": "SSH凭据描述",
"searchCredentials": "按名称、用户名或标签搜索凭据...",
"keyPassword": "密钥密码", "keyPassword": "密钥密码",
"sshConfig": "端点 SSH 配置", "sshConfig": "端点 SSH 配置",
"homePath": "/home", "homePath": "/home",
@@ -818,6 +849,7 @@
"userIdField": "sub", "userIdField": "sub",
"usernameField": "name", "usernameField": "name",
"scopes": "openid email profile", "scopes": "openid email profile",
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
"enterUsername": "输入用户名以设为管理员", "enterUsername": "输入用户名以设为管理员",
"searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...", "searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...",
"enterPassword": "输入您的密码", "enterPassword": "输入您的密码",
+22 -6
View File
@@ -144,6 +144,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
username, username,
password, password,
authMethod, authMethod,
authType,
credentialId,
key, key,
keyPassword, keyPassword,
keyType, keyType,
@@ -160,6 +162,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
return res.status(400).json({error: 'Invalid SSH data'}); return res.status(400).json({error: 'Invalid SSH data'});
} }
const effectiveAuthType = authType || authMethod;
const sshDataObj: any = { const sshDataObj: any = {
userId: userId, userId: userId,
name, name,
@@ -168,7 +171,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
ip, ip,
port, port,
username, username,
authType: authMethod, authType: effectiveAuthType,
credentialId: credentialId || null,
pin: !!pin ? 1 : 0, pin: !!pin ? 1 : 0,
enableTerminal: !!enableTerminal ? 1 : 0, enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0, enableTunnel: !!enableTunnel ? 1 : 0,
@@ -177,12 +181,12 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
}; };
if (authMethod === 'password') { if (effectiveAuthType === 'password') {
sshDataObj.password = password; sshDataObj.password = password;
sshDataObj.key = null; sshDataObj.key = null;
sshDataObj.keyPassword = null; sshDataObj.keyPassword = null;
sshDataObj.keyType = null; sshDataObj.keyType = null;
} else if (authMethod === 'key') { } else if (effectiveAuthType === 'key') {
sshDataObj.key = key; sshDataObj.key = key;
sshDataObj.keyPassword = keyPassword; sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType; sshDataObj.keyType = keyType;
@@ -232,6 +236,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
username, username,
password, password,
authMethod, authMethod,
authType,
credentialId,
key, key,
keyPassword, keyPassword,
keyType, keyType,
@@ -249,6 +255,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
return res.status(400).json({error: 'Invalid SSH data'}); return res.status(400).json({error: 'Invalid SSH data'});
} }
const effectiveAuthType = authType || authMethod;
const sshDataObj: any = { const sshDataObj: any = {
name, name,
folder, folder,
@@ -256,7 +263,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
ip, ip,
port, port,
username, username,
authType: authMethod, authType: effectiveAuthType,
credentialId: credentialId || null,
pin: !!pin ? 1 : 0, pin: !!pin ? 1 : 0,
enableTerminal: !!enableTerminal ? 1 : 0, enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0, enableTunnel: !!enableTunnel ? 1 : 0,
@@ -265,15 +273,23 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
}; };
if (authMethod === 'password') { if (effectiveAuthType === 'password') {
if (password) {
sshDataObj.password = password; sshDataObj.password = password;
}
sshDataObj.key = null; sshDataObj.key = null;
sshDataObj.keyPassword = null; sshDataObj.keyPassword = null;
sshDataObj.keyType = null; sshDataObj.keyType = null;
} else if (authMethod === 'key') { } else if (effectiveAuthType === 'key') {
if (key) {
sshDataObj.key = key; sshDataObj.key = key;
}
if (keyPassword !== undefined) {
sshDataObj.keyPassword = keyPassword; sshDataObj.keyPassword = keyPassword;
}
if (keyType) {
sshDataObj.keyType = keyType; sshDataObj.keyType = keyType;
}
sshDataObj.password = null; sshDataObj.password = null;
} }
+9 -7
View File
@@ -34,7 +34,9 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
try { try {
setLoading(true); setLoading(true);
const data = await getCredentials(); const data = await getCredentials();
setCredentials(data.credentials || []); // Handle both possible response formats: direct array or nested object
const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []);
setCredentials(credentialsArray);
} catch (error) { } catch (error) {
console.error('Failed to fetch credentials:', error); console.error('Failed to fetch credentials:', error);
setCredentials([]); setCredentials([]);
@@ -102,7 +104,7 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
ref={buttonRef} ref={buttonRef}
type="button" type="button"
variant="outline" variant="outline"
className="w-full justify-between text-left rounded-md px-3 py-2 bg-[#18181b] border border-input text-foreground" className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
onClick={() => setDropdownOpen(!dropdownOpen)} onClick={() => setDropdownOpen(!dropdownOpen)}
> >
{loading ? ( {loading ? (
@@ -127,9 +129,9 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
{dropdownOpen && ( {dropdownOpen && (
<div <div
ref={dropdownRef} ref={dropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-80 overflow-hidden" className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
> >
<div className="p-2 border-b border-input"> <div className="p-2 border-b border-border">
<Input <Input
placeholder={t('credentials.searchCredentials')} placeholder={t('credentials.searchCredentials')}
value={searchQuery} value={searchQuery}
@@ -154,7 +156,7 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start text-left rounded-md px-2 py-2 text-red-400 hover:bg-red-500/20" className="w-full justify-start text-left rounded-lg px-2 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
onClick={handleClear} onClick={handleClear}
> >
{t('common.clear')} {t('common.clear')}
@@ -166,8 +168,8 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className={`w-full justify-start text-left rounded-md px-2 py-2 hover:bg-white/15 focus:bg-white/20 focus:outline-none ${ className={`w-full justify-start text-left rounded-lg px-2 py-2 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
credential.id === value ? 'bg-white/20' : '' credential.id === value ? 'bg-muted' : ''
}`} }`}
onClick={() => handleCredentialSelect(credential)} onClick={() => handleCredentialSelect(credential)}
> >
+40 -47
View File
@@ -208,7 +208,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden"> className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2"> <div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">Admin Settings</h1> <h1 className="font-bold text-lg">{t('admin.title')}</h1>
</div> </div>
<Separator className="p-0.25 w-full"/> <Separator className="p-0.25 w-full"/>
@@ -221,11 +221,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2"> <TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/> <Shield className="h-4 w-4"/>
OIDC {t('admin.oidc')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2"> <TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/> <Users className="h-4 w-4"/>
Users {t('admin.users')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2"> <TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/> <Shield className="h-4 w-4"/>
@@ -246,9 +246,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsContent value="oidc" className="space-y-6"> <TabsContent value="oidc" className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3> <h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
<p className="text-sm text-muted-foreground">Configure external identity provider for <p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
OIDC/OAuth2 authentication.</p>
{oidcError && ( {oidcError && (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -259,50 +258,50 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4"> <form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label> <Label htmlFor="client_id">{t('admin.clientId')}</Label>
<Input id="client_id" value={oidcConfig.client_id} <Input id="client_id" value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)} onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id" required/> placeholder={t('placeholders.clientId')} required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label> <Label htmlFor="client_secret">{t('admin.clientSecret')}</Label>
<Input id="client_secret" type="password" value={oidcConfig.client_secret} <Input id="client_secret" type="password" value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)} onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret" required/> placeholder={t('placeholders.clientSecret')} required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label> <Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label>
<Input id="authorization_url" value={oidcConfig.authorization_url} <Input id="authorization_url" value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)} onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/" placeholder={t('placeholders.authUrl')}
required/> required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label> <Label htmlFor="issuer_url">{t('admin.issuerUrl')}</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url} <Input id="issuer_url" value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)} onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/" required/> placeholder={t('placeholders.redirectUrl')} required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label> <Label htmlFor="token_url">{t('admin.tokenUrl')}</Label>
<Input id="token_url" value={oidcConfig.token_url} <Input id="token_url" value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/" required/> placeholder={t('placeholders.tokenUrl')} required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label> <Label htmlFor="identifier_path">{t('admin.userIdentifierPath')}</Label>
<Input id="identifier_path" value={oidcConfig.identifier_path} <Input id="identifier_path" value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)} onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub" required/> placeholder={t('placeholders.userIdField')} required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label> <Label htmlFor="name_path">{t('admin.displayNamePath')}</Label>
<Input id="name_path" value={oidcConfig.name_path} <Input id="name_path" value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)} onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name" required/> placeholder={t('placeholders.usernameField')} required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label> <Label htmlFor="scopes">{t('admin.scopes')}</Label>
<Input id="scopes" value={oidcConfig.scopes} <Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)} onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
placeholder={t('placeholders.scopes')} required/> placeholder={t('placeholders.scopes')} required/>
@@ -311,17 +310,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<Label htmlFor="userinfo_url">{t('admin.overrideUserInfoUrl')}</Label> <Label htmlFor="userinfo_url">{t('admin.overrideUserInfoUrl')}</Label>
<Input id="userinfo_url" value={oidcConfig.userinfo_url} <Input id="userinfo_url" value={oidcConfig.userinfo_url}
onChange={(e) => handleOIDCConfigChange('userinfo_url', e.target.value)} onChange={(e) => handleOIDCConfigChange('userinfo_url', e.target.value)}
placeholder="https://your-provider.com/application/o/userinfo/"/> placeholder={t('placeholders.userinfoUrl')}/>
</div>
<div className="space-y-2">
<Label htmlFor="userinfo_url">{t('admin.overrideUserInfoUrl')}</Label>
<Input id="userinfo_url" value={oidcConfig.userinfo_url}
onChange={(e) => handleOIDCConfigChange('userinfo_url', e.target.value)}
placeholder="https://your-provider.com/application/o/userinfo/"/>
</div> </div>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1" <Button type="submit" className="flex-1"
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button> disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({ <Button type="button" variant="outline" onClick={() => setOidcConfig({
client_id: '', client_id: '',
client_secret: '', client_secret: '',
@@ -341,20 +334,20 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsContent value="users" className="space-y-6"> <TabsContent value="users" className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3> <h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline" <Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button> size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
</div> </div>
{usersLoading ? ( {usersLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading users...</div> <div className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
) : ( ) : (
<div className="border rounded-md overflow-hidden"> <div className="border rounded-md overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="px-4">Username</TableHead> <TableHead className="px-4">{t('admin.username')}</TableHead>
<TableHead className="px-4">Type</TableHead> <TableHead className="px-4">{t('admin.type')}</TableHead>
<TableHead className="px-4">Actions</TableHead> <TableHead className="px-4">{t('admin.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -364,11 +357,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
{user.username} {user.username}
{user.is_admin && ( {user.is_admin && (
<span <span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span> className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
)} )}
</TableCell> </TableCell>
<TableCell <TableCell
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell> className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" <Button variant="ghost" size="sm"
onClick={() => handleDeleteUser(user.username)} onClick={() => handleDeleteUser(user.username)}
@@ -388,18 +381,18 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsContent value="admins" className="space-y-6"> <TabsContent value="admins" className="space-y-6">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="text-lg font-semibold">Admin Management</h3> <h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3>
<div className="space-y-4 p-6 border rounded-md bg-muted/50"> <div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">Make User Admin</h4> <h4 className="font-medium">{t('admin.makeUserAdmin')}</h4>
<form onSubmit={handleMakeUserAdmin} className="space-y-4"> <form onSubmit={handleMakeUserAdmin} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label> <Label htmlFor="new-admin-username">{t('admin.username')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername} <Input id="new-admin-username" value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)} onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder={t('admin.enterUsernameToMakeAdmin')} required/> placeholder={t('admin.enterUsernameToMakeAdmin')} required/>
<Button type="submit" <Button type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button> disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
</div> </div>
</div> </div>
{makeAdminError && ( {makeAdminError && (
@@ -413,14 +406,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-medium">Current Admins</h4> <h4 className="font-medium">{t('admin.currentAdmins')}</h4>
<div className="border rounded-md overflow-hidden"> <div className="border rounded-md overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="px-4">Username</TableHead> <TableHead className="px-4">{t('admin.username')}</TableHead>
<TableHead className="px-4">Type</TableHead> <TableHead className="px-4">{t('admin.type')}</TableHead>
<TableHead className="px-4">Actions</TableHead> <TableHead className="px-4">{t('admin.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -432,13 +425,13 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span> className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
</TableCell> </TableCell>
<TableCell <TableCell
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell> className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" <Button variant="ghost" size="sm"
onClick={() => handleRemoveAdminStatus(admin.username)} onClick={() => handleRemoveAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-900/20 dark:hover:bg-orange-900/30"> className="text-orange-600 hover:text-orange-700 hover:bg-orange-900/20 dark:hover:bg-orange-900/30">
<Shield className="h-4 w-4"/> <Shield className="h-4 w-4"/>
Remove Admin {t('admin.removeAdminButton')}
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
File diff suppressed because it is too large Load Diff
@@ -1,33 +1,25 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { import {
Plus,
Search, Search,
Key, Key,
User,
Calendar,
Hash,
Folder, Folder,
Edit3, Edit,
Trash2, Trash2,
Copy,
Settings,
ChevronDown,
ChevronRight,
Shield, Shield,
Clock, Pin,
Server Tag,
Info
} from 'lucide-react'; } from 'lucide-react';
import { getCredentials, getCredentialFolders, deleteCredential } from '@/ui/main-axios'; import { getCredentials, deleteCredential } from '@/ui/main-axios';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CredentialEditor from './CredentialEditor'; import {CredentialEditor} from './CredentialEditor';
import CredentialViewer from './CredentialViewer'; import CredentialViewer from './CredentialViewer';
interface Credential { interface Credential {
@@ -45,137 +37,95 @@ interface Credential {
updatedAt: string; updatedAt: string;
} }
interface GroupedCredentials { interface CredentialsManagerProps {
[folder: string]: Credential[]; onEditCredential?: (credential: Credential) => void;
} }
const CredentialsManager: React.FC = () => { export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]); const [credentials, setCredentials] = useState<Credential[]>([]);
const [filteredCredentials, setFilteredCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedFolder, setSelectedFolder] = useState<string>('all');
const [selectedAuthType, setSelectedAuthType] = useState<string>('all');
const [showEditor, setShowEditor] = useState(false);
const [showViewer, setShowViewer] = useState(false); const [showViewer, setShowViewer] = useState(false);
const [editingCredential, setEditingCredential] = useState<Credential | null>(null);
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null); const [viewingCredential, setViewingCredential] = useState<Credential | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [viewMode, setViewMode] = useState<'list' | 'folder'>('list');
useEffect(() => { useEffect(() => {
fetchCredentials(); fetchCredentials();
}, []); }, []);
useEffect(() => {
filterCredentials();
}, [credentials, searchQuery, selectedFolder, selectedAuthType]);
const fetchCredentials = async () => { const fetchCredentials = async () => {
try { try {
const response = await getCredentials(); setLoading(true);
setCredentials(response); const data = await getCredentials();
} catch (error) { setCredentials(data);
console.error('Failed to fetch credentials:', error); setError(null);
toast.error(t('credentials.failedToFetchCredentials')); } catch (err) {
setError(t('credentials.failedToFetchCredentials'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const filterCredentials = () => {
const handleEdit = (credential: Credential) => {
if (onEditCredential) {
onEditCredential(credential);
}
};
const handleDelete = async (credentialId: number, credentialName: string) => {
if (window.confirm(t('credentials.confirmDeleteCredential', { name: credentialName }))) {
try {
await deleteCredential(credentialId);
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed'));
} catch (err) {
toast.error(t('credentials.failedToDeleteCredential'));
}
}
};
const filteredAndSortedCredentials = useMemo(() => {
let filtered = credentials; let filtered = credentials;
if (searchQuery) { if (searchQuery.trim()) {
filtered = filtered.filter(cred => const query = searchQuery.toLowerCase();
cred.name.toLowerCase().includes(searchQuery.toLowerCase()) || filtered = credentials.filter(credential => {
cred.username.toLowerCase().includes(searchQuery.toLowerCase()) || const searchableText = [
cred.description?.toLowerCase().includes(searchQuery.toLowerCase()) || credential.name || '',
cred.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase())) credential.username,
); credential.description || '',
...(credential.tags || []),
credential.authType,
credential.keyType || ''
].join(' ').toLowerCase();
return searchableText.includes(query);
});
} }
if (selectedFolder !== 'all') { return filtered.sort((a, b) => {
if (selectedFolder === 'none') { const aName = a.name || a.username;
filtered = filtered.filter(cred => !cred.folder); const bName = b.name || b.username;
} else { return aName.localeCompare(bName);
filtered = filtered.filter(cred => cred.folder === selectedFolder); });
} }, [credentials, searchQuery]);
}
if (selectedAuthType !== 'all') { const credentialsByFolder = useMemo(() => {
filtered = filtered.filter(cred => cred.authType === selectedAuthType); const grouped: { [key: string]: Credential[] } = {};
}
setFilteredCredentials(filtered); filteredAndSortedCredentials.forEach(credential => {
};
const handleCreateCredential = () => {
setEditingCredential(null);
setShowEditor(true);
};
const handleEditCredential = (credential: Credential) => {
setEditingCredential(credential);
setShowEditor(true);
};
const handleViewCredential = (credential: Credential) => {
setViewingCredential(credential);
setShowViewer(true);
};
const handleDeleteCredential = async (credential: Credential) => {
if (!confirm(t('credentials.confirmDeleteCredential', { name: credential.name }))) {
return;
}
try {
await deleteCredential(credential.id);
toast.success(t('credentials.credentialDeletedSuccessfully'));
fetchCredentials();
} catch (error: any) {
console.error('Failed to delete credential:', error);
toast.error(error.response?.data?.error || t('credentials.failedToDeleteCredential'));
}
};
const handleDuplicateCredential = (credential: Credential) => {
const duplicated: Credential = {
...credential,
id: 0, // Will be assigned by server
name: `${credential.name} (Copy)`,
usageCount: 0,
lastUsed: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
setEditingCredential(duplicated);
setShowEditor(true);
};
const handleCredentialSaved = () => {
setShowEditor(false);
setEditingCredential(null);
fetchCredentials();
};
const toggleFolder = (folder: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(folder)) {
newExpanded.delete(folder);
} else {
newExpanded.add(folder);
}
setExpandedFolders(newExpanded);
};
const groupCredentialsByFolder = (credentials: Credential[]): GroupedCredentials => {
const grouped: GroupedCredentials = {};
credentials.forEach(credential => {
const folder = credential.folder || t('credentials.uncategorized'); const folder = credential.folder || t('credentials.uncategorized');
if (!grouped[folder]) { if (!grouped[folder]) {
grouped[folder] = []; grouped[folder] = [];
@@ -183,342 +133,193 @@ const CredentialsManager: React.FC = () => {
grouped[folder].push(credential); grouped[folder].push(credential);
}); });
return grouped; const sortedFolders = Object.keys(grouped).sort((a, b) => {
}; if (a === t('credentials.uncategorized')) return -1;
if (b === t('credentials.uncategorized')) return 1;
return a.localeCompare(b);
});
const getUniqueValues = (field: keyof Credential): string[] => { const sortedGrouped: { [key: string]: Credential[] } = {};
const values = credentials sortedFolders.forEach(folder => {
.map(cred => cred[field]) sortedGrouped[folder] = grouped[folder];
.filter((value): value is string => typeof value === 'string' && value.length > 0); });
return Array.from(new Set(values));
};
const renderCredentialCard = (credential: Credential) => ( return sortedGrouped;
<Card key={credential.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors border-zinc-200 dark:border-zinc-700"> }, [filteredAndSortedCredentials, t]);
<CardHeader className="pb-4">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
{credential.authType === 'password' ? (
<Key className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
) : (
<Shield className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
)}
</div>
<div>
<CardTitle className="text-sm font-medium">{credential.name}</CardTitle>
{credential.description && (
<CardDescription className="text-xs mt-1">
{credential.description}
</CardDescription>
)}
</div>
</div>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewCredential(credential)}
title={t('credentials.viewCredential')}
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditCredential(credential)}
title={t('credentials.editCredential')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDuplicateCredential(credential)}
title={t('credentials.duplicateCredential')}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteCredential(credential)}
title={t('credentials.deleteCredential')}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3 text-sm">
<div className="flex items-center space-x-3">
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<span className="text-zinc-700 dark:text-zinc-300 font-medium">{credential.username}</span>
<Badge variant="outline" className="text-xs border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
{credential.authType}
</Badge>
{credential.keyType && (
<Badge variant="secondary" className="text-xs bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
{credential.keyType}
</Badge>
)}
</div>
{credential.tags.length > 0 && (
<div className="flex items-center space-x-2 flex-wrap gap-1">
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
{credential.tags.map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
{tag}
</Badge>
))}
</div>
)}
<div className="flex items-center justify-between pt-3 border-t border-zinc-200 dark:border-zinc-700">
<div className="flex items-center space-x-4 text-zinc-500 dark:text-zinc-400">
<div className="flex items-center space-x-1.5">
<Server className="h-3.5 w-3.5" />
<span className="text-xs">{credential.usageCount}</span>
</div>
{credential.lastUsed && (
<div className="flex items-center space-x-1.5">
<Clock className="h-3.5 w-3.5" />
<span className="text-xs">{new Date(credential.lastUsed).toLocaleDateString()}</span>
</div>
)}
</div>
<div className="flex items-center space-x-1.5 text-zinc-500 dark:text-zinc-400">
<Calendar className="h-3.5 w-3.5" />
<span className="text-xs">{new Date(credential.createdAt).toLocaleDateString()}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
const renderListView = () => (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{filteredCredentials.map(renderCredentialCard)}
</div>
);
const renderFolderView = () => {
const grouped = groupCredentialsByFolder(filteredCredentials);
return (
<div className="space-y-4">
{Object.entries(grouped).map(([folder, folderCredentials]) => (
<div key={folder} className="space-y-2">
<div
className="flex items-center space-x-3 cursor-pointer p-3 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
onClick={() => toggleFolder(folder)}
>
{expandedFolders.has(folder) ? (
<ChevronDown className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
)}
<Folder className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
<span className="font-medium text-zinc-800 dark:text-zinc-200">{folder === t('credentials.uncategorized') ? t('credentials.uncategorized') : folder}</span>
<Badge variant="secondary" className="text-xs bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
{folderCredentials.length}
</Badge>
</div>
{expandedFolders.has(folder) && (
<div className="ml-8 grid gap-6 md:grid-cols-2 xl:grid-cols-3 pt-2">
{folderCredentials.map(renderCredentialCard)}
</div>
)}
</div>
))}
</div>
);
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <div className="text-center">
<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('credentials.loadingCredentials')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchCredentials} variant="outline">
{t('credentials.retry')}
</Button>
</div>
</div>
);
}
if (credentials.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Key className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
<h3 className="text-lg font-semibold mb-2">{t('credentials.noCredentials')}</h3>
<p className="text-muted-foreground mb-4">
{t('credentials.noCredentialsMessage')}
</p>
</div>
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col h-full min-h-0 overflow-hidden"> <div className="flex flex-col h-full min-h-0">
{/* Header */} <div className="flex items-center justify-between mb-2">
<div className="flex items-center justify-between p-8 pb-6"> <div>
<div className="space-y-2"> <h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
<h1 className="text-3xl font-bold">{t('credentials.credentialsManager')}</h1> <p className="text-muted-foreground">
<p className="text-zinc-600 dark:text-zinc-400 text-lg"> {t('credentials.credentialsCount', { count: filteredAndSortedCredentials.length })}
{t('credentials.manageYourSSHCredentials')}
</p> </p>
</div> </div>
<Button onClick={handleCreateCredential} size="lg"> <div className="flex items-center gap-2">
<Plus className="h-5 w-5 mr-2" /> <Button onClick={fetchCredentials} variant="outline" size="sm">
{t('credentials.addCredential')} {t('credentials.refresh')}
</Button> </Button>
</div> </div>
</div>
{/* Filters */} <div className="relative mb-3">
<div className="px-8 pb-6"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Card>
<CardContent className="pt-8">
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input <Input
placeholder={t('credentials.searchCredentials')} placeholder={t('placeholders.searchCredentials')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10"
/> />
</div> </div>
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-2 pb-20">
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => (
<div key={folder} className="border rounded-md">
<Accordion type="multiple" defaultValue={Object.keys(credentialsByFolder)}>
<AccordionItem value={folder} className="border-none">
<AccordionTrigger
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2">
<Folder className="h-4 w-4"/>
<span className="font-medium">{folder}</span>
<Badge variant="secondary" className="text-xs">
{folderCredentials.length}
</Badge>
</div> </div>
<Select value={selectedFolder} onValueChange={setSelectedFolder}> </AccordionTrigger>
<SelectTrigger className="w-full md:w-48"> <AccordionContent className="p-2">
<SelectValue placeholder={t('credentials.selectFolder')} /> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
</SelectTrigger> {folderCredentials.map((credential) => (
<SelectContent> <div
<SelectItem value="all">{t('credentials.allFolders')}</SelectItem> key={credential.id}
<SelectItem value="none">{t('credentials.uncategorized')}</SelectItem> className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
{getUniqueValues('folder').map(folder => ( onClick={() => handleEdit(credential)}
<SelectItem key={folder} value={folder}>{folder}</SelectItem> >
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<h3 className="font-medium truncate text-sm">
{credential.name || `${credential.username}`}
</h3>
</div>
<p className="text-xs text-muted-foreground truncate">
{credential.username}
</p>
<p className="text-xs text-muted-foreground truncate">
{credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')}
</p>
</div>
<div className="flex gap-1 flex-shrink-0 ml-1">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleEdit(credential);
}}
className="h-5 w-5 p-0"
>
<Edit className="h-3 w-3"/>
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(credential.id, credential.name || credential.username);
}}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3"/>
</Button>
</div>
</div>
<div className="mt-2 space-y-1">
{credential.tags && credential.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{credential.tags.slice(0, 6).map((tag, index) => (
<Badge key={index} variant="outline"
className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5"/>
{tag}
</Badge>
))} ))}
</SelectContent> {credential.tags.length > 6 && (
</Select> <Badge variant="outline"
<Select value={selectedAuthType} onValueChange={setSelectedAuthType}> className="text-xs px-1 py-0">
<SelectTrigger className="w-full md:w-48"> +{credential.tags.length - 6}
<SelectValue placeholder={t('credentials.selectAuthType')} /> </Badge>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('credentials.allAuthTypes')}</SelectItem>
<SelectItem value="password">{t('common.password')}</SelectItem>
<SelectItem value="key">{t('credentials.sshKey')}</SelectItem>
</SelectContent>
</Select>
<div className="flex border rounded-md">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="rounded-r-none"
>
{t('credentials.listView')}
</Button>
<Button
variant={viewMode === 'folder' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('folder')}
className="rounded-l-none"
>
{t('credentials.folderView')}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="px-8 py-4">
<Separator />
</div>
{/* Stats */}
<div className="px-8 pb-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<Card>
<CardContent className="pt-6">
<div className="text-center space-y-2">
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">{credentials.length}</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.totalCredentials')}</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center space-y-2">
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">
{credentials.filter(c => c.authType === 'key').length}
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.keyBased')}</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center space-y-2">
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">
{credentials.filter(c => c.authType === 'password').length}
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.passwordBased')}</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center space-y-2">
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">
{getUniqueValues('folder').length}
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.folders')}</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Credentials List */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden px-8 pb-8">
<Card className="flex-1 flex flex-col min-h-0">
<CardHeader className="pb-6">
<CardTitle className="text-xl">
{t('nav.credentials')} ({filteredCredentials.length})
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 px-6">
<ScrollArea className="flex-1">
{filteredCredentials.length === 0 ? (
<div className="text-center py-16 text-zinc-500 dark:text-zinc-400">
{searchQuery || selectedFolder !== 'all' || selectedAuthType !== 'all' ? (
<div className="space-y-4">
<Search className="h-16 w-16 mx-auto text-zinc-300 dark:text-zinc-600" />
<p className="text-lg">{t('credentials.noCredentialsMatchFilters')}</p>
</div>
) : (
<div className="space-y-6">
<Key className="h-16 w-16 mx-auto text-zinc-300 dark:text-zinc-600" />
<div className="space-y-2">
<p className="text-lg font-medium">{t('credentials.noCredentialsYet')}</p>
</div>
<Button size="lg" onClick={handleCreateCredential}>
<Plus className="h-5 w-5 mr-2" />
{t('credentials.createFirstCredential')}
</Button>
</div>
)} )}
</div> </div>
) : (
viewMode === 'list' ? renderListView() : renderFolderView()
)} )}
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-xs px-1 py-0">
{credential.authType === 'password' ? (
<Key className="h-2 w-2 mr-0.5"/>
) : (
<Shield className="h-2 w-2 mr-0.5"/>
)}
{credential.authType}
</Badge>
{credential.authType === 'key' && credential.keyType && (
<Badge variant="outline" className="text-xs px-1 py-0">
{credential.keyType}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
))}
</div>
</ScrollArea> </ScrollArea>
</CardContent>
</Card>
</div>
{/* Modals */}
{showEditor && (
<CredentialEditor
credential={editingCredential}
onSave={handleCredentialSaved}
onCancel={() => setShowEditor(false)}
/>
)}
{showViewer && viewingCredential && ( {showViewer && viewingCredential && (
<CredentialViewer <CredentialViewer
@@ -526,12 +327,10 @@ const CredentialsManager: React.FC = () => {
onClose={() => setShowViewer(false)} onClose={() => setShowViewer(false)}
onEdit={() => { onEdit={() => {
setShowViewer(false); setShowViewer(false);
handleEditCredential(viewingCredential); handleEdit(viewingCredential);
}} }}
/> />
)} )}
</div> </div>
); );
}; }
export default CredentialsManager;
@@ -3,7 +3,8 @@ import {HostManagerHostViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerH
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {Separator} from "@/components/ui/separator.tsx"; import {Separator} from "@/components/ui/separator.tsx";
import {HostManagerHostEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx"; import {HostManagerHostEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx";
import CredentialsManager from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx"; import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
@@ -39,6 +40,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
const {t} = useTranslation(); const {t} = useTranslation();
const [activeTab, setActiveTab] = useState("host_viewer"); const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null); const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const [editingCredential, setEditingCredential] = useState<any | null>(null);
const {state: sidebarState} = useSidebar(); const {state: sidebarState} = useSidebar();
const handleEditHost = (host: SSHHost) => { const handleEditHost = (host: SSHHost) => {
@@ -51,11 +53,24 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
setActiveTab("host_viewer"); setActiveTab("host_viewer");
}; };
const handleEditCredential = (credential: any) => {
setEditingCredential(credential);
setActiveTab("add_credential");
};
const handleCredentialFormSubmit = () => {
setEditingCredential(null);
setActiveTab("credentials");
};
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
setActiveTab(value); setActiveTab(value);
if (value === "host_viewer") { if (value === "host_viewer") {
setEditingHost(null); setEditingHost(null);
} }
if (value === "credentials") {
setEditingCredential(null);
}
}; };
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
@@ -83,6 +98,9 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
{editingHost ? t('hosts.editHost') : t('hosts.addHost')} {editingHost ? t('hosts.editHost') : t('hosts.addHost')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="credentials">{t('credentials.credentialsManager')}</TabsTrigger> <TabsTrigger value="credentials">{t('credentials.credentialsManager')}</TabsTrigger>
<TabsTrigger value="add_credential">
{editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/> <Separator className="p-0.25 -mt-0.5 mb-1"/>
@@ -100,7 +118,16 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
<TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/> <Separator className="p-0.25 -mt-0.5 mb-1"/>
<div className="flex flex-col h-full min-h-0 overflow-auto"> <div className="flex flex-col h-full min-h-0 overflow-auto">
<CredentialsManager /> <CredentialsManager onEditCredential={handleEditCredential} />
</div>
</TabsContent>
<TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/>
<div className="flex flex-col h-full min-h-0">
<CredentialEditor
editingCredential={editingCredential}
onFormSubmit={handleCredentialFormSubmit}
/>
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
+2
View File
@@ -340,6 +340,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
tags: hostData.tags || [], tags: hostData.tags || [],
pin: hostData.pin || false, pin: hostData.pin || false,
authMethod: hostData.authType, authMethod: hostData.authType,
authType: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '', password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null, key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '', keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
@@ -392,6 +393,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
tags: hostData.tags || [], tags: hostData.tags || [],
pin: hostData.pin || false, pin: hostData.pin || false,
authMethod: hostData.authType, authMethod: hostData.authType,
authType: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '', password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null, key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '', keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',