diff options
Diffstat (limited to 'packages/renderer')
4 files changed, 547 insertions, 21 deletions
diff --git a/packages/renderer/src/components/errorPage/CertificateDetails.tsx b/packages/renderer/src/components/errorPage/CertificateDetails.tsx index 044cb5c..51e7920 100644 --- a/packages/renderer/src/components/errorPage/CertificateDetails.tsx +++ b/packages/renderer/src/components/errorPage/CertificateDetails.tsx | |||
@@ -19,22 +19,41 @@ | |||
19 | */ | 19 | */ |
20 | 20 | ||
21 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; | 21 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; |
22 | import WarningAmberIcon from '@mui/icons-material/WarningAmber'; | ||
23 | import Accordion from '@mui/material/Accordion'; | 22 | import Accordion from '@mui/material/Accordion'; |
24 | import AccordionDetails from '@mui/material/AccordionDetails'; | 23 | import AccordionDetails from '@mui/material/AccordionDetails'; |
25 | import AccordionSummary from '@mui/material/AccordionSummary'; | 24 | import AccordionSummary from '@mui/material/AccordionSummary'; |
26 | import Box from '@mui/material/Box'; | 25 | import Box from '@mui/material/Box'; |
27 | import Button from '@mui/material/Button'; | 26 | import Tab from '@mui/material/Tab'; |
27 | import Tabs from '@mui/material/Tabs'; | ||
28 | import Typography from '@mui/material/Typography'; | 28 | import Typography from '@mui/material/Typography'; |
29 | import { styled } from '@mui/material/styles'; | ||
30 | import type { Certificate } from '@sophie/shared'; | ||
29 | import { observer } from 'mobx-react-lite'; | 31 | import { observer } from 'mobx-react-lite'; |
30 | import React from 'react'; | 32 | import React, { useState } from 'react'; |
31 | import { useTranslation } from 'react-i18next'; | 33 | import { useTranslation } from 'react-i18next'; |
32 | 34 | ||
33 | import type Service from '../../stores/Service'; | 35 | import type Service from '../../stores/Service'; |
34 | 36 | ||
37 | import SingleCertificateDetails from './SingleCertificateDetails'; | ||
38 | import TrustCertificateDialog from './TrustCertificateDialog'; | ||
39 | |||
35 | const SUMMARY_ID = 'Sophie-CertificateDetails-header'; | 40 | const SUMMARY_ID = 'Sophie-CertificateDetails-header'; |
36 | const DETAILS_ID = 'Sophie-CertificateDetails-content'; | 41 | const DETAILS_ID = 'Sophie-CertificateDetails-content'; |
37 | 42 | ||
43 | function getCertificateChain(endEntityCertificate: Certificate): Certificate[] { | ||
44 | const chain: Certificate[] = []; | ||
45 | let certificate: Certificate | undefined = endEntityCertificate; | ||
46 | while (certificate !== undefined) { | ||
47 | chain.push(certificate); | ||
48 | certificate = certificate.issuerCert as Certificate; | ||
49 | } | ||
50 | return chain; | ||
51 | } | ||
52 | |||
53 | const DetailsBox = styled(Box)(({ theme }) => ({ | ||
54 | padding: `0 ${theme.spacing(2)}`, | ||
55 | })); | ||
56 | |||
38 | function CertificateDetails({ | 57 | function CertificateDetails({ |
39 | service, | 58 | service, |
40 | }: { | 59 | }: { |
@@ -43,8 +62,9 @@ function CertificateDetails({ | |||
43 | const { t } = useTranslation(undefined, { | 62 | const { t } = useTranslation(undefined, { |
44 | keyPrefix: 'error.certificateError.details', | 63 | keyPrefix: 'error.certificateError.details', |
45 | }); | 64 | }); |
65 | const [certificateIndex, setCertificateIndex] = useState(0); | ||
46 | 66 | ||
47 | const { state } = service; | 67 | const { id: serviceId, state } = service; |
48 | const { type: errorType } = state; | 68 | const { type: errorType } = state; |
49 | 69 | ||
50 | if (errorType !== 'certificateError') { | 70 | if (errorType !== 'certificateError') { |
@@ -54,6 +74,22 @@ function CertificateDetails({ | |||
54 | 74 | ||
55 | const { certificate, trust } = state; | 75 | const { certificate, trust } = state; |
56 | 76 | ||
77 | const chain = getCertificateChain(certificate); | ||
78 | |||
79 | if (chain.length === 0) { | ||
80 | // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering. | ||
81 | return null; | ||
82 | } | ||
83 | |||
84 | const id = `${serviceId}-certificateDetails`; | ||
85 | |||
86 | const trustCertificateDialog = ( | ||
87 | <TrustCertificateDialog | ||
88 | trust={trust} | ||
89 | onClick={() => service.temporarilyTrustCurrentCertificate()} | ||
90 | /> | ||
91 | ); | ||
92 | |||
57 | return ( | 93 | return ( |
58 | <Accordion | 94 | <Accordion |
59 | disableGutters | 95 | disableGutters |
@@ -67,23 +103,81 @@ function CertificateDetails({ | |||
67 | > | 103 | > |
68 | <Typography>{t('title')}</Typography> | 104 | <Typography>{t('title')}</Typography> |
69 | </AccordionSummary> | 105 | </AccordionSummary> |
70 | <AccordionDetails> | 106 | <AccordionDetails |
71 | <Typography sx={{ wordWrap: 'break-word' }}> | 107 | sx={{ |
72 | {JSON.stringify(certificate)} | 108 | px: 0, |
73 | </Typography> | 109 | pt: 0, |
74 | <Typography mt={2}> | 110 | }} |
75 | <WarningAmberIcon fontSize="small" /> <strong>{t('warning')}</strong> | 111 | > |
76 | </Typography> | 112 | {chain.length >= 2 ? ( |
77 | <Box mt={1}> | 113 | <> |
78 | <Button | 114 | <Tabs |
79 | disabled={trust !== 'pending'} | 115 | aria-label={t<string>('tabsLabel')} |
80 | onClick={() => service.temporarilyTrustCurrentCertificate()} | 116 | value={certificateIndex < chain.length ? certificateIndex : false} |
81 | color="error" | 117 | onChange={(_event, value) => setCertificateIndex(value as number)} |
82 | variant="outlined" | 118 | variant="fullWidth" |
83 | > | 119 | > |
84 | {t(`acceptButton.${trust}`)} | 120 | {chain.map((member, i) => ( |
85 | </Button> | 121 | <Tab |
86 | </Box> | 122 | key={member.fingerprint} |
123 | value={i} | ||
124 | id={`${id}-tab-${i}`} | ||
125 | aria-controls={`${id}-tabpanel-${i}`} | ||
126 | label={ | ||
127 | member.subjectName === '' | ||
128 | ? member.fingerprint | ||
129 | : member.subjectName | ||
130 | } | ||
131 | /> | ||
132 | ))} | ||
133 | </Tabs> | ||
134 | <DetailsBox | ||
135 | sx={(theme) => ({ | ||
136 | paddingTop: 1, | ||
137 | borderTop: `1px solid ${theme.palette.divider}`, | ||
138 | })} | ||
139 | > | ||
140 | {chain.map((member, i) => ( | ||
141 | <div | ||
142 | key={member.fingerprint} | ||
143 | role="tabpanel" | ||
144 | hidden={certificateIndex !== i} | ||
145 | id={`${id}-tabpanel-${i}`} | ||
146 | aria-labelledby={`${id}-tab-${i}`} | ||
147 | > | ||
148 | {certificateIndex === i && ( | ||
149 | <> | ||
150 | <SingleCertificateDetails | ||
151 | detailsId={id} | ||
152 | certificate={member} | ||
153 | onIssuerClick={ | ||
154 | i < chain.length - 1 | ||
155 | ? () => setCertificateIndex(i + 1) | ||
156 | : undefined | ||
157 | } | ||
158 | onDownloadClick={() => | ||
159 | service.downloadCertificate(member.fingerprint) | ||
160 | } | ||
161 | /> | ||
162 | {i === 0 && trustCertificateDialog} | ||
163 | </> | ||
164 | )} | ||
165 | </div> | ||
166 | ))} | ||
167 | </DetailsBox> | ||
168 | </> | ||
169 | ) : ( | ||
170 | <DetailsBox> | ||
171 | <SingleCertificateDetails | ||
172 | detailsId={id} | ||
173 | certificate={chain[0]} | ||
174 | onDownloadClick={() => | ||
175 | service.downloadCertificate(chain[0].fingerprint) | ||
176 | } | ||
177 | /> | ||
178 | {trustCertificateDialog} | ||
179 | </DetailsBox> | ||
180 | )} | ||
87 | </AccordionDetails> | 181 | </AccordionDetails> |
88 | </Accordion> | 182 | </Accordion> |
89 | ); | 183 | ); |
diff --git a/packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx b/packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx new file mode 100644 index 0000000..107d9f1 --- /dev/null +++ b/packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx | |||
@@ -0,0 +1,369 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import AttachmentIcon from '@mui/icons-material/Attachment'; | ||
22 | import Link from '@mui/material/Link'; | ||
23 | import { styled } from '@mui/material/styles'; | ||
24 | import type { Certificate, CertificatePrincipal } from '@sophie/shared'; | ||
25 | import type { TFunction } from 'i18next'; | ||
26 | import { observer } from 'mobx-react-lite'; | ||
27 | import React, { type ReactNode } from 'react'; | ||
28 | import { useTranslation } from 'react-i18next'; | ||
29 | |||
30 | const SingleCertificateDetailsRoot = styled('table')(({ theme }) => ({ | ||
31 | width: '100%', | ||
32 | borderSpacing: `${theme.spacing(2)} 0`, | ||
33 | })); | ||
34 | |||
35 | const Header = styled('th')({ | ||
36 | textAlign: 'right', | ||
37 | whiteSpace: 'nowrap', | ||
38 | verticalAlign: 'top', | ||
39 | }); | ||
40 | |||
41 | const SectionHeader = styled(Header)(({ theme }) => ({ | ||
42 | padding: `${theme.spacing(2)} 0`, | ||
43 | })); | ||
44 | |||
45 | const SubHeader = styled(Header)(({ theme }) => ({ | ||
46 | color: theme.palette.text.secondary, | ||
47 | })); | ||
48 | |||
49 | const Cell = styled('td')({ | ||
50 | verticalAlign: 'top', | ||
51 | }); | ||
52 | |||
53 | function Section({ id, label }: { id: string; label: string }): JSX.Element { | ||
54 | return ( | ||
55 | <tr> | ||
56 | <SectionHeader id={id} scope="colgroup"> | ||
57 | {label} | ||
58 | </SectionHeader> | ||
59 | <Cell aria-hidden="true" /> | ||
60 | </tr> | ||
61 | ); | ||
62 | } | ||
63 | |||
64 | function Row({ | ||
65 | id, | ||
66 | label, | ||
67 | headers, | ||
68 | children, | ||
69 | }: { | ||
70 | id: string; | ||
71 | label: string; | ||
72 | headers: string; | ||
73 | children: ReactNode; | ||
74 | }): JSX.Element { | ||
75 | return ( | ||
76 | <tr> | ||
77 | <SubHeader id={id} scope="row"> | ||
78 | {label} | ||
79 | </SubHeader> | ||
80 | <Cell headers={`${headers} ${id}`}>{children}</Cell> | ||
81 | </tr> | ||
82 | ); | ||
83 | } | ||
84 | |||
85 | function OptionalRow({ | ||
86 | id, | ||
87 | label, | ||
88 | value, | ||
89 | headers, | ||
90 | }: { | ||
91 | id: string; | ||
92 | label: string; | ||
93 | value: string; | ||
94 | headers: string; | ||
95 | }): JSX.Element | null { | ||
96 | if (value === '') { | ||
97 | // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering. | ||
98 | return null; | ||
99 | } | ||
100 | |||
101 | return ( | ||
102 | <Row id={id} label={label} headers={headers}> | ||
103 | {value} | ||
104 | </Row> | ||
105 | ); | ||
106 | } | ||
107 | |||
108 | function DateTimeRow({ | ||
109 | id, | ||
110 | label, | ||
111 | value, | ||
112 | locales, | ||
113 | headers, | ||
114 | }: { | ||
115 | id: string; | ||
116 | label: string; | ||
117 | value: number; | ||
118 | locales: readonly string[]; | ||
119 | headers: string; | ||
120 | }): JSX.Element { | ||
121 | // Electron provides `validStart` and `validExpiry` in seconds, | ||
122 | // but `Date` expects milliseconds. | ||
123 | const date = new Date(value * 1000); | ||
124 | const dateStr = new Intl.DateTimeFormat([...locales], { | ||
125 | dateStyle: 'long', | ||
126 | timeStyle: 'long', | ||
127 | }).format(date); | ||
128 | |||
129 | return ( | ||
130 | <Row id={id} label={label} headers={headers}> | ||
131 | {dateStr} | ||
132 | </Row> | ||
133 | ); | ||
134 | } | ||
135 | |||
136 | function Principal({ | ||
137 | id, | ||
138 | label, | ||
139 | principal: { | ||
140 | country, | ||
141 | state, | ||
142 | locality, | ||
143 | organizations, | ||
144 | organizationUnits, | ||
145 | commonName, | ||
146 | }, | ||
147 | t, | ||
148 | onIssuerClick, | ||
149 | }: { | ||
150 | id: string; | ||
151 | label: string; | ||
152 | principal: CertificatePrincipal; | ||
153 | t: TFunction; | ||
154 | onIssuerClick?: (() => void) | undefined; | ||
155 | }): JSX.Element { | ||
156 | return ( | ||
157 | <> | ||
158 | <Section id={id} label={label} /> | ||
159 | <OptionalRow | ||
160 | id={`${id}-country`} | ||
161 | label={t<string>('country')} | ||
162 | value={country} | ||
163 | headers={id} | ||
164 | /> | ||
165 | <OptionalRow | ||
166 | id={`${id}-state`} | ||
167 | label={t<string>('state')} | ||
168 | value={state} | ||
169 | headers={id} | ||
170 | /> | ||
171 | <OptionalRow | ||
172 | id={`${id}-locality`} | ||
173 | label={t<string>('locality')} | ||
174 | value={locality} | ||
175 | headers={id} | ||
176 | /> | ||
177 | {/* | ||
178 | eslint-disable react/no-array-index-key -- | ||
179 | These entries can freely contain repetitions, so we can only tell them apart by index. | ||
180 | */} | ||
181 | {organizations.map((value, i) => ( | ||
182 | <OptionalRow | ||
183 | key={i} | ||
184 | id={`${id}-organization-${i}`} | ||
185 | label={t<string>('organization')} | ||
186 | value={value} | ||
187 | headers={id} | ||
188 | /> | ||
189 | ))} | ||
190 | {organizationUnits.map((value, i) => ( | ||
191 | <OptionalRow | ||
192 | key={i} | ||
193 | id={`${id}-organizationUnit-${i}`} | ||
194 | label={t<string>('organizationUnit')} | ||
195 | value={value} | ||
196 | headers={id} | ||
197 | /> | ||
198 | ))} | ||
199 | {/* eslint-enable react/no-array-index-key */} | ||
200 | {commonName !== '' && ( | ||
201 | <Row | ||
202 | id={`${id}-commonName`} | ||
203 | label={t<string>('commonName')} | ||
204 | headers={id} | ||
205 | > | ||
206 | {onIssuerClick === undefined ? ( | ||
207 | commonName | ||
208 | ) : ( | ||
209 | // eslint-disable-next-line jsx-a11y/anchor-is-valid -- This is a `button`. | ||
210 | <Link | ||
211 | component="button" | ||
212 | variant="body1" | ||
213 | onClick={onIssuerClick} | ||
214 | sx={{ verticalAlign: 'baseline' }} | ||
215 | > | ||
216 | {commonName} | ||
217 | </Link> | ||
218 | )} | ||
219 | </Row> | ||
220 | )} | ||
221 | </> | ||
222 | ); | ||
223 | } | ||
224 | |||
225 | Principal.defaultProps = { | ||
226 | onIssuerClick: undefined, | ||
227 | }; | ||
228 | |||
229 | function parseFingerprint(fingerprint: string): { | ||
230 | algorithm: string | undefined; | ||
231 | formattedValue: string; | ||
232 | } { | ||
233 | const separatorIndex = fingerprint.indexOf('/'); | ||
234 | if (separatorIndex <= 0) { | ||
235 | return { | ||
236 | algorithm: undefined, | ||
237 | formattedValue: fingerprint, | ||
238 | }; | ||
239 | } | ||
240 | return { | ||
241 | algorithm: fingerprint.slice(0, separatorIndex).toUpperCase(), | ||
242 | formattedValue: fingerprint.slice(separatorIndex + 1), | ||
243 | }; | ||
244 | } | ||
245 | |||
246 | function Fingerprint({ | ||
247 | id, | ||
248 | label, | ||
249 | fallbackAlgorithmLabel, | ||
250 | value, | ||
251 | }: { | ||
252 | id: string; | ||
253 | label: string; | ||
254 | fallbackAlgorithmLabel: string; | ||
255 | value: string; | ||
256 | }) { | ||
257 | const { algorithm, formattedValue } = parseFingerprint(value); | ||
258 | return ( | ||
259 | <> | ||
260 | <Section id={id} label={label} /> | ||
261 | <OptionalRow | ||
262 | id={`${id}-fingerprint`} | ||
263 | label={algorithm ?? fallbackAlgorithmLabel} | ||
264 | headers={id} | ||
265 | value={formattedValue} | ||
266 | /> | ||
267 | </> | ||
268 | ); | ||
269 | } | ||
270 | |||
271 | function SingleCertificateDetails({ | ||
272 | detailsId, | ||
273 | certificate: { | ||
274 | subject, | ||
275 | issuer, | ||
276 | validStart, | ||
277 | validExpiry, | ||
278 | fingerprint, | ||
279 | serialNumber, | ||
280 | }, | ||
281 | onIssuerClick, | ||
282 | onDownloadClick, | ||
283 | }: { | ||
284 | detailsId: string; | ||
285 | certificate: Certificate; | ||
286 | onIssuerClick?: (() => void) | undefined; | ||
287 | onDownloadClick: () => void; | ||
288 | }): JSX.Element { | ||
289 | const { | ||
290 | t, | ||
291 | i18n: { languages }, | ||
292 | } = useTranslation(undefined, { | ||
293 | keyPrefix: 'error.certificateError.details', | ||
294 | }); | ||
295 | |||
296 | const id = `${detailsId}-${fingerprint}-certificate`; | ||
297 | |||
298 | return ( | ||
299 | <SingleCertificateDetailsRoot> | ||
300 | <tbody> | ||
301 | <Principal | ||
302 | id={`${id}-subject`} | ||
303 | label={t<string>('subject')} | ||
304 | principal={subject} | ||
305 | t={t} | ||
306 | /> | ||
307 | <Principal | ||
308 | id={`${id}-issuer`} | ||
309 | label={t<string>('issuer')} | ||
310 | principal={issuer} | ||
311 | t={t} | ||
312 | onIssuerClick={onIssuerClick} | ||
313 | /> | ||
314 | <Section id={`${id}-validity`} label={t<string>('validity')} /> | ||
315 | <DateTimeRow | ||
316 | id={`${id}-validity-validStart`} | ||
317 | label={t<string>('validStart')} | ||
318 | value={validStart} | ||
319 | locales={languages} | ||
320 | headers={`${id}-validity`} | ||
321 | /> | ||
322 | <DateTimeRow | ||
323 | id={`${id}-validity-validExpiry`} | ||
324 | label={t<string>('validExpiry')} | ||
325 | value={validExpiry} | ||
326 | locales={languages} | ||
327 | headers={`${id}-validity`} | ||
328 | /> | ||
329 | <Section | ||
330 | id={`${id}-miscellaneous`} | ||
331 | label={t<string>('miscellaneous')} | ||
332 | /> | ||
333 | <OptionalRow | ||
334 | id={`${id}-miscellaneous-serialNumber`} | ||
335 | label={t<string>('serialNumber')} | ||
336 | value={serialNumber} | ||
337 | headers={`${id}-miscellaneous`} | ||
338 | /> | ||
339 | <Row | ||
340 | id={`${id}-miscellaneous-download`} | ||
341 | label={t<string>('download')} | ||
342 | headers={`${id}-miscellaneous`} | ||
343 | > | ||
344 | {/* eslint-disable-next-line jsx-a11y/anchor-is-valid -- This is a `button`. */} | ||
345 | <Link | ||
346 | component="button" | ||
347 | variant="body1" | ||
348 | onClick={onDownloadClick} | ||
349 | sx={{ verticalAlign: 'baseline' }} | ||
350 | > | ||
351 | <AttachmentIcon fontSize="inherit" /> {t('downloadPEM')} | ||
352 | </Link> | ||
353 | </Row> | ||
354 | <Fingerprint | ||
355 | id={`${id}-fingerprint`} | ||
356 | label={t<string>('fingerprint')} | ||
357 | fallbackAlgorithmLabel={t<string>('fingerprintUnknown')} | ||
358 | value={fingerprint} | ||
359 | /> | ||
360 | </tbody> | ||
361 | </SingleCertificateDetailsRoot> | ||
362 | ); | ||
363 | } | ||
364 | |||
365 | SingleCertificateDetails.defaultProps = { | ||
366 | onIssuerClick: undefined, | ||
367 | }; | ||
368 | |||
369 | export default observer(SingleCertificateDetails); | ||
diff --git a/packages/renderer/src/components/errorPage/TrustCertificateDialog.tsx b/packages/renderer/src/components/errorPage/TrustCertificateDialog.tsx new file mode 100644 index 0000000..630cbe0 --- /dev/null +++ b/packages/renderer/src/components/errorPage/TrustCertificateDialog.tsx | |||
@@ -0,0 +1,57 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import WarningAmberIcon from '@mui/icons-material/WarningAmber'; | ||
22 | import Box from '@mui/material/Box'; | ||
23 | import Button from '@mui/material/Button'; | ||
24 | import Typography from '@mui/material/Typography'; | ||
25 | import React from 'react'; | ||
26 | import { useTranslation } from 'react-i18next'; | ||
27 | |||
28 | export default function TrustCertificateDialog({ | ||
29 | trust, | ||
30 | onClick, | ||
31 | }: { | ||
32 | trust: 'pending' | 'rejected' | 'accepted'; | ||
33 | onClick: () => void; | ||
34 | }): JSX.Element { | ||
35 | const { t } = useTranslation(undefined, { | ||
36 | keyPrefix: 'error.certificateError.details', | ||
37 | }); | ||
38 | |||
39 | return ( | ||
40 | <> | ||
41 | <Typography mt={2}> | ||
42 | <WarningAmberIcon fontSize="inherit" />{' '} | ||
43 | <strong>{t('trustWarning')}</strong> | ||
44 | </Typography> | ||
45 | <Box mt={1}> | ||
46 | <Button | ||
47 | disabled={trust !== 'pending'} | ||
48 | onClick={onClick} | ||
49 | color="error" | ||
50 | variant="outlined" | ||
51 | > | ||
52 | {t(`trustButton.${trust}`)} | ||
53 | </Button> | ||
54 | </Box> | ||
55 | </> | ||
56 | ); | ||
57 | } | ||
diff --git a/packages/renderer/src/stores/Service.ts b/packages/renderer/src/stores/Service.ts index 4510ec0..dcaf96e 100644 --- a/packages/renderer/src/stores/Service.ts +++ b/packages/renderer/src/stores/Service.ts | |||
@@ -123,6 +123,12 @@ const Service = defineServiceModel(ServiceSettings) | |||
123 | action: 'dismiss-all-popups', | 123 | action: 'dismiss-all-popups', |
124 | }); | 124 | }); |
125 | }, | 125 | }, |
126 | downloadCertificate(fingerprint: string): void { | ||
127 | dispatch({ | ||
128 | action: 'download-certificate', | ||
129 | fingerprint, | ||
130 | }); | ||
131 | }, | ||
126 | }; | 132 | }; |
127 | }) | 133 | }) |
128 | .actions((self) => ({ | 134 | .actions((self) => ({ |