Skip to content

Commit f948ef7

Browse files
feat(components): improve SSH key registration by adding a modal with paste support
1 parent cdee343 commit f948ef7

7 files changed

Lines changed: 234 additions & 21 deletions

File tree

public/locales/settings/en.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,22 @@
2323

2424
"profile.ssh.label": "SSH Key",
2525
"profile.ssh.tooltip": "Register your SSH key to access Dokku",
26-
"profile.ssh.button": "Select a public SSH key",
26+
"profile.ssh.button": "Register a public SSH key",
2727
"profile.ssh.registering": "Registering...",
2828
"profile.ssh.registered": "Key registered successfully",
2929
"profile.ssh.registerFailed": "An unexpected error occurred while saving the SSH key",
3030
"profile.ssh.registerNotAllowed": "You do not have permission to register SSH keys",
3131

32+
"profile.ssh.modal.title": "Register SSH Key",
33+
"profile.ssh.modal.description": "Add your public SSH key to access Dokku. You can paste the key directly or select the .pub file.",
34+
"profile.ssh.modal.pasteLabel": "Paste your public SSH key",
35+
"profile.ssh.modal.placeholder": "ssh-ed25519 AAAA... your_email@example.com",
36+
"profile.ssh.modal.save": "Register key",
37+
"profile.ssh.modal.saving": "Registering...",
38+
"profile.ssh.modal.or": "or",
39+
"profile.ssh.modal.selectFile": "Select a .pub file",
40+
"profile.ssh.modal.cancel": "Cancel",
41+
3242
"profile.ssh.tutorial.summary": "How to create and register an SSH key?",
3343
"profile.ssh.tutorial.step1.title": "1. Generate an SSH key pair",
3444
"profile.ssh.tutorial.step1.description": "Run the following command in your terminal. Press Enter on all prompts to use the default settings.",

public/locales/settings/pt.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,22 @@
2323

2424
"profile.ssh.label": "Chave SSH",
2525
"profile.ssh.tooltip": "Registre sua chave SSH para acessar o Dokku",
26-
"profile.ssh.button": "Selecionar chave pública SSH",
26+
"profile.ssh.button": "Registrar chave pública SSH",
2727
"profile.ssh.registering": "Registrando...",
2828
"profile.ssh.registered": "Chave registrada com sucesso",
2929
"profile.ssh.registerFailed": "Ocorreu um erro inesperado ao salvar a chave SSH",
3030
"profile.ssh.registerNotAllowed": "Você não tem permissão para registrar chaves SSH",
3131

32+
"profile.ssh.modal.title": "Registrar chave SSH",
33+
"profile.ssh.modal.description": "Adicione sua chave SSH pública para acessar o Dokku. Você pode colar a chave diretamente ou selecionar o arquivo .pub.",
34+
"profile.ssh.modal.pasteLabel": "Cole sua chave SSH pública",
35+
"profile.ssh.modal.placeholder": "ssh-ed25519 AAAA... seu_email@exemplo.com",
36+
"profile.ssh.modal.save": "Registrar chave",
37+
"profile.ssh.modal.saving": "Registrando...",
38+
"profile.ssh.modal.or": "ou",
39+
"profile.ssh.modal.selectFile": "Selecionar arquivo .pub",
40+
"profile.ssh.modal.cancel": "Cancelar",
41+
3242
"profile.ssh.tutorial.summary": "Como criar e registrar uma chave SSH?",
3343
"profile.ssh.tutorial.step1.title": "1. Gere um par de chaves SSH",
3444
"profile.ssh.tutorial.step1.description": "Execute o comando abaixo no seu terminal. Pressione Enter em todas as perguntas para usar as configurações padrão.",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './profile-card';
2+
export * from './save-ssh-key-modal';

src/components/pages/settings/components/profile-card/profile-card.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { usePageTranslation } from '@/i18n/utils';
3333
import { LANGUAGE_NAMES } from '@/lib/utils';
3434

3535
import styles from '../../settings.module.css';
36+
import { SaveSSHKeyModal } from './save-ssh-key-modal';
3637

3738
interface ProfileCardProps {
3839
session?: Session;
@@ -42,10 +43,12 @@ interface ProfileCardProps {
4243
showToken: boolean;
4344
onToggleShowToken: () => void;
4445
onCopyToken: () => void | Promise<void>;
45-
onOpenSaveSSHKey: () => void;
46+
onSelectSSHKeyFile: () => void;
47+
onSubmitSSHKey: (rawKey: string) => void | Promise<void>;
4648
isSSHKeySaving: boolean;
4749
isSSHKeyRegistered: boolean;
4850
sshErrorMessage?: string | null;
51+
sshErrorDetail?: string | null;
4952
}
5053

5154
export function ProfileCard({
@@ -56,14 +59,17 @@ export function ProfileCard({
5659
showToken,
5760
onToggleShowToken,
5861
onCopyToken,
59-
onOpenSaveSSHKey,
62+
onSelectSSHKeyFile,
63+
onSubmitSSHKey,
6064
isSSHKeySaving,
6165
isSSHKeyRegistered,
6266
sshErrorMessage,
67+
sshErrorDetail,
6368
}: ProfileCardProps) {
6469
const { t } = usePageTranslation();
6570
const { mode, toggleTheme } = useTheme();
6671
const [copied, setCopied] = useState<string | null>(null);
72+
const [sshModalOpen, setSSHModalOpen] = useState(false);
6773

6874
const copy = (id: string, text: string) => {
6975
navigator.clipboard.writeText(text);
@@ -228,7 +234,7 @@ export function ProfileCard({
228234
color='orange'
229235
variant='outline'
230236
style={{ cursor: 'pointer' }}
231-
onClick={onOpenSaveSSHKey}
237+
onClick={() => setSSHModalOpen(true)}
232238
disabled={isSSHKeySaving || isSSHKeyRegistered}
233239
>
234240
{isSSHKeyRegistered ? (
@@ -248,7 +254,6 @@ export function ProfileCard({
248254
</>
249255
)}
250256
</Button>
251-
{sshErrorMessage && <Text className={styles.sshErrorMessage}>{sshErrorMessage}</Text>}
252257
</Flex>
253258
</Flex>
254259

@@ -401,6 +406,16 @@ export function ProfileCard({
401406
</Flex>
402407
</Flex>
403408
</Flex>
409+
<SaveSSHKeyModal
410+
open={sshModalOpen}
411+
onOpenChange={setSSHModalOpen}
412+
onSelectFile={onSelectSSHKeyFile}
413+
onSubmitText={onSubmitSSHKey}
414+
isSaving={isSSHKeySaving}
415+
isRegistered={isSSHKeyRegistered}
416+
errorMessage={sshErrorMessage}
417+
errorDetail={sshErrorDetail}
418+
/>
404419
</Card>
405420
);
406421
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { ReloadIcon, UploadIcon } from '@radix-ui/react-icons';
2+
import { Box, Button, Dialog, Flex, Separator, Text, TextArea } from '@radix-ui/themes';
3+
import { useEffect, useState } from 'react';
4+
5+
import { usePageTranslation } from '@/i18n/utils';
6+
7+
import styles from '../../settings.module.css';
8+
9+
interface SaveSSHKeyModalProps {
10+
open: boolean;
11+
onOpenChange: (open: boolean) => void;
12+
onSelectFile: () => void;
13+
onSubmitText: (rawKey: string) => void | Promise<void>;
14+
isSaving: boolean;
15+
isRegistered: boolean;
16+
errorMessage?: string | null;
17+
errorDetail?: string | null;
18+
}
19+
20+
export function SaveSSHKeyModal({
21+
open,
22+
onOpenChange,
23+
onSelectFile,
24+
onSubmitText,
25+
isSaving,
26+
isRegistered,
27+
errorMessage,
28+
errorDetail,
29+
}: SaveSSHKeyModalProps) {
30+
const { t } = usePageTranslation();
31+
const [text, setText] = useState('');
32+
33+
useEffect(() => {
34+
if (!open) setText('');
35+
}, [open]);
36+
37+
useEffect(() => {
38+
if (isRegistered && open) onOpenChange(false);
39+
}, [isRegistered, open, onOpenChange]);
40+
41+
const trimmed = text.trim();
42+
const canSubmit = !!trimmed && !isSaving;
43+
44+
const handleSubmit = () => {
45+
if (!canSubmit) return;
46+
onSubmitText(trimmed);
47+
};
48+
49+
return (
50+
<Dialog.Root
51+
open={open}
52+
onOpenChange={(nextOpen) => {
53+
if (isSaving && !nextOpen) return;
54+
onOpenChange(nextOpen);
55+
}}
56+
>
57+
<Dialog.Content maxWidth='500px'>
58+
<Dialog.Title>{t('profile.ssh.modal.title')}</Dialog.Title>
59+
<Dialog.Description size='2' style={{ color: 'var(--gray-11)' }}>
60+
{t('profile.ssh.modal.description')}
61+
</Dialog.Description>
62+
63+
<Box mt='4'>
64+
<Text
65+
as='label'
66+
size='2'
67+
weight='medium'
68+
style={{ color: 'var(--gray-12)', display: 'block', marginBottom: '6px' }}
69+
>
70+
{t('profile.ssh.modal.pasteLabel')}
71+
</Text>
72+
<TextArea
73+
color='orange'
74+
placeholder={t('profile.ssh.modal.placeholder')}
75+
value={text}
76+
onChange={(e) => setText(e.target.value)}
77+
disabled={isSaving}
78+
rows={6}
79+
style={{
80+
fontFamily: 'monospace',
81+
fontSize: '12px',
82+
minHeight: '120px',
83+
resize: 'vertical',
84+
}}
85+
/>
86+
<Button
87+
color='orange'
88+
onClick={handleSubmit}
89+
disabled={!canSubmit}
90+
style={{ cursor: 'pointer', marginTop: '10px', width: '100%' }}
91+
>
92+
{isSaving ? (
93+
<>
94+
<ReloadIcon className={styles.buttonSpinner} />
95+
{t('profile.ssh.modal.saving')}
96+
</>
97+
) : (
98+
t('profile.ssh.modal.save')
99+
)}
100+
</Button>
101+
</Box>
102+
103+
<Flex align='center' gap='3' style={{ margin: '18px 0' }}>
104+
<Separator size='4' style={{ flex: 1 }} />
105+
<Text size='1' style={{ color: 'var(--gray-9)', textTransform: 'uppercase' }}>
106+
{t('profile.ssh.modal.or')}
107+
</Text>
108+
<Separator size='4' style={{ flex: 1 }} />
109+
</Flex>
110+
111+
<Button
112+
color='orange'
113+
variant='outline'
114+
onClick={onSelectFile}
115+
disabled={isSaving}
116+
style={{ width: '100%', cursor: 'pointer' }}
117+
>
118+
<UploadIcon />
119+
{t('profile.ssh.modal.selectFile')}
120+
</Button>
121+
122+
{errorMessage && (
123+
<Box style={{ marginTop: '12px' }}>
124+
<Text size='2' color='red' style={{ display: 'block' }}>
125+
{errorMessage}
126+
</Text>
127+
{errorDetail && (
128+
<pre
129+
style={{
130+
marginTop: '8px',
131+
marginBottom: 0,
132+
padding: '8px 12px',
133+
background: 'var(--gray-3)',
134+
border: '1px solid var(--gray-5)',
135+
borderRadius: 'var(--radius-2)',
136+
fontFamily: 'monospace',
137+
fontSize: '12px',
138+
color: 'var(--gray-12)',
139+
whiteSpace: 'pre-wrap',
140+
wordBreak: 'break-word',
141+
overflowX: 'auto',
142+
}}
143+
>
144+
{errorDetail}
145+
</pre>
146+
)}
147+
</Box>
148+
)}
149+
150+
<Flex gap='3' mt='4' justify='end'>
151+
<Dialog.Close>
152+
<Button variant='soft' color='gray' style={{ cursor: 'pointer' }} disabled={isSaving}>
153+
{t('profile.ssh.modal.cancel')}
154+
</Button>
155+
</Dialog.Close>
156+
</Flex>
157+
</Dialog.Content>
158+
</Dialog.Root>
159+
);
160+
}

src/components/pages/settings/settings.module.css

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,6 @@
7777
max-width: 400px;
7878
}
7979

80-
.sshErrorMessage {
81-
color: red;
82-
}
83-
8480
@media (max-width: 550px) {
8581
.userSettingsContainer {
8682
flex-direction: column;

src/components/pages/settings/settings.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function SettingsPage(props: SettingsPageProps) {
3838
// Error states
3939
const [errors, setErrors] = useState({
4040
ssh: null as string | null,
41+
sshDetail: null as string | null,
4142
});
4243

4344
useEffect(() => {
@@ -93,14 +94,15 @@ export function SettingsPage(props: SettingsPageProps) {
9394
}
9495
};
9596

96-
const registerSSHKey = async (ssh_key: string) => {
97+
const registerSSHKey = async (rawSSHKey: string) => {
9798
setIsSSHKeySaving(true);
9899
setIsSSHKeyRegistered(false);
99-
setErrors((prev) => ({ ...prev, ssh: null }));
100+
setErrors((prev) => ({ ...prev, ssh: null, sshDetail: null }));
100101

101102
try {
103+
const encoded = btoa(rawSSHKey);
102104
const response = await api.post(`/api/ssh/key`, undefined, {
103-
params: { public_ssh_key: ssh_key },
105+
params: { public_ssh_key: encoded },
104106
});
105107
if (response.status === 200) {
106108
if (!response?.data?.success) {
@@ -109,28 +111,45 @@ export function SettingsPage(props: SettingsPageProps) {
109111
setIsSSHKeyRegistered(true);
110112
}
111113
} catch (error: any) {
114+
const detail =
115+
typeof error?.response?.data?.message === 'string'
116+
? error.response.data.message
117+
: typeof error?.message === 'string'
118+
? error.message
119+
: null;
112120
if (error?.response?.status === 403) {
113-
setErrors((prev) => ({ ...prev, ssh: t('profile.ssh.registerNotAllowed') }));
121+
setErrors((prev) => ({
122+
...prev,
123+
ssh: t('profile.ssh.registerNotAllowed'),
124+
sshDetail: detail,
125+
}));
114126
} else {
115-
setErrors((prev) => ({ ...prev, ssh: t('profile.ssh.registerFailed') }));
127+
setErrors((prev) => ({
128+
...prev,
129+
ssh: t('profile.ssh.registerFailed'),
130+
sshDetail: detail,
131+
}));
116132
}
117133
console.error('Failed to register SSH key:', error);
118134
} finally {
119135
setIsSSHKeySaving(false);
120136
}
121137
};
122138

123-
const handleRegisterSSHKey = async (event: React.ChangeEvent<HTMLInputElement>) => {
139+
const handleRegisterSSHKeyFromFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
124140
const file = event.target.files?.[0];
125141
event.target.value = '';
126142
if (!file) return;
127143

128144
try {
129-
const content = btoa(await file.text());
130-
await registerSSHKey(content);
145+
await registerSSHKey(await file.text());
131146
} catch (err) {
132147
console.error('Error registering ssh key:', err);
133-
setErrors((prev) => ({ ...prev, ssh: t('profile.ssh.registerFailed') }));
148+
setErrors((prev) => ({
149+
...prev,
150+
ssh: t('profile.ssh.registerFailed'),
151+
sshDetail: null,
152+
}));
134153
}
135154
};
136155

@@ -143,7 +162,7 @@ export function SettingsPage(props: SettingsPageProps) {
143162
type='file'
144163
accept='.pub'
145164
style={{ display: 'none' }}
146-
onChange={handleRegisterSSHKey}
165+
onChange={handleRegisterSSHKeyFromFile}
147166
/>
148167
<NavBar session={session} />
149168

@@ -172,10 +191,12 @@ export function SettingsPage(props: SettingsPageProps) {
172191
showToken={showToken}
173192
onToggleShowToken={() => setShowToken(!showToken)}
174193
onCopyToken={copyToken}
175-
onOpenSaveSSHKey={() => document.getElementById('ssh-file-upload')?.click()}
194+
onSelectSSHKeyFile={() => document.getElementById('ssh-file-upload')?.click()}
195+
onSubmitSSHKey={registerSSHKey}
176196
isSSHKeySaving={isSSHKeySaving}
177197
isSSHKeyRegistered={isSSHKeyRegistered}
178198
sshErrorMessage={errors.ssh}
199+
sshErrorDetail={errors.sshDetail}
179200
/>
180201
<ResourcesCard quota={quota} loading={loading} error={error} />
181202
</Flex>

0 commit comments

Comments
 (0)