diff options
-rw-r--r-- | locales/en/translation.json | 24 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/electronShell.ts | 15 | ||||
-rw-r--r-- | packages/main/src/stores/MainEnv.ts | 2 | ||||
-rw-r--r-- | packages/main/src/stores/Service.ts | 41 | ||||
-rw-r--r-- | packages/renderer/src/components/errorPage/CertificateDetails.tsx | 136 | ||||
-rw-r--r-- | packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx | 369 | ||||
-rw-r--r-- | packages/renderer/src/components/errorPage/TrustCertificateDialog.tsx | 57 | ||||
-rw-r--r-- | packages/renderer/src/stores/Service.ts | 6 | ||||
-rw-r--r-- | packages/shared/src/index.ts | 5 | ||||
-rw-r--r-- | packages/shared/src/schemas/ServiceAction.ts | 4 | ||||
-rw-r--r-- | packages/shared/src/stores/Certificate.ts | 8 |
11 files changed, 638 insertions, 29 deletions
diff --git a/locales/en/translation.json b/locales/en/translation.json index a669d28..321e6be 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json | |||
@@ -91,12 +91,30 @@ | |||
91 | }, | 91 | }, |
92 | "details": { | 92 | "details": { |
93 | "title": "More info", | 93 | "title": "More info", |
94 | "warning": "You should only trust certificates that you've verified to be valid. Trusting invalid certificates allows attackers to steal your passwords and data.", | 94 | "tabsLabel": "Certificates in the certificate chain", |
95 | "acceptButton": { | 95 | "trustWarning": "You should only trust certificates that you've verified to be valid. Trusting invalid certificates allows attackers to steal your passwords and data.", |
96 | "trustButton": { | ||
96 | "pending": "Trust this certificate temporarily", | 97 | "pending": "Trust this certificate temporarily", |
97 | "rejected": "You have already rejected this certificate", | 98 | "rejected": "You have already rejected this certificate", |
98 | "accepted": "You have already accepted this certificate" | 99 | "accepted": "You have already accepted this certificate" |
99 | } | 100 | }, |
101 | "subject": "Subject", | ||
102 | "issuer": "Issuer", | ||
103 | "commonName": "Common Name", | ||
104 | "country": "Country", | ||
105 | "state": "State", | ||
106 | "locality": "Locality", | ||
107 | "organization": "Organization", | ||
108 | "organizationUnit": "Organization Unit", | ||
109 | "validity": "Validity", | ||
110 | "validStart": "Not Before", | ||
111 | "validExpiry": "Not After", | ||
112 | "fingerprint": "Fingerprint", | ||
113 | "fingerprintUnknown": "Unknown", | ||
114 | "miscellaneous": "Miscellaneous", | ||
115 | "serialNumber": "Serial Number", | ||
116 | "download": "Download", | ||
117 | "downloadPEM": "PEM Certificate" | ||
100 | } | 118 | } |
101 | }, | 119 | }, |
102 | "crashed": { | 120 | "crashed": { |
diff --git a/packages/main/src/infrastructure/electron/impl/electronShell.ts b/packages/main/src/infrastructure/electron/impl/electronShell.ts index be1cbe3..f7f7001 100644 --- a/packages/main/src/infrastructure/electron/impl/electronShell.ts +++ b/packages/main/src/infrastructure/electron/impl/electronShell.ts | |||
@@ -18,7 +18,9 @@ | |||
18 | * SPDX-License-Identifier: AGPL-3.0-only | 18 | * SPDX-License-Identifier: AGPL-3.0-only |
19 | */ | 19 | */ |
20 | 20 | ||
21 | import { app, shell } from 'electron'; | 21 | import { writeFile } from 'node:fs/promises'; |
22 | |||
23 | import { app, dialog, shell } from 'electron'; | ||
22 | import { getLogger } from 'loglevel'; | 24 | import { getLogger } from 'loglevel'; |
23 | 25 | ||
24 | import type MainEnv from '../../../stores/MainEnv'; | 26 | import type MainEnv from '../../../stores/MainEnv'; |
@@ -34,6 +36,17 @@ const electronShell: MainEnv = { | |||
34 | openAboutDialog(): void { | 36 | openAboutDialog(): void { |
35 | app.showAboutPanel(); | 37 | app.showAboutPanel(); |
36 | }, | 38 | }, |
39 | async saveTextFile(filename: string, value: string): Promise<void> { | ||
40 | const result = await dialog.showSaveDialog({ | ||
41 | defaultPath: filename, | ||
42 | }); | ||
43 | if (result.canceled || result.filePath === undefined) { | ||
44 | log.debug('Saving file', filename, 'canceled'); | ||
45 | return; | ||
46 | } | ||
47 | await writeFile(result.filePath, value, 'utf8'); | ||
48 | log.debug('Saved file', filename, 'to', result.filePath); | ||
49 | }, | ||
37 | }; | 50 | }; |
38 | 51 | ||
39 | export default electronShell; | 52 | export default electronShell; |
diff --git a/packages/main/src/stores/MainEnv.ts b/packages/main/src/stores/MainEnv.ts index 8923322..f9dc209 100644 --- a/packages/main/src/stores/MainEnv.ts +++ b/packages/main/src/stores/MainEnv.ts | |||
@@ -28,4 +28,6 @@ export default interface MainEnv { | |||
28 | openURLInExternalBrowser(url: string): void; | 28 | openURLInExternalBrowser(url: string): void; |
29 | 29 | ||
30 | openAboutDialog(): void; | 30 | openAboutDialog(): void; |
31 | |||
32 | saveTextFile(filename: string, value: string): Promise<void>; | ||
31 | } | 33 | } |
diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts index d8f3166..1d46dc9 100644 --- a/packages/main/src/stores/Service.ts +++ b/packages/main/src/stores/Service.ts | |||
@@ -20,12 +20,13 @@ | |||
20 | 20 | ||
21 | import type { UnreadCount } from '@sophie/service-shared'; | 21 | import type { UnreadCount } from '@sophie/service-shared'; |
22 | import { | 22 | import { |
23 | CertificateSnapshotIn, | 23 | type Certificate, |
24 | type CertificateSnapshotIn, | ||
24 | defineServiceModel, | 25 | defineServiceModel, |
25 | ServiceAction, | 26 | ServiceAction, |
26 | ServiceStateSnapshotIn, | 27 | type ServiceStateSnapshotIn, |
27 | } from '@sophie/shared'; | 28 | } from '@sophie/shared'; |
28 | import { Instance, getSnapshot, cast } from 'mobx-state-tree'; | 29 | import { type Instance, getSnapshot, cast, flow } from 'mobx-state-tree'; |
29 | 30 | ||
30 | import type { ServiceView } from '../infrastructure/electron/types'; | 31 | import type { ServiceView } from '../infrastructure/electron/types'; |
31 | import { getLogger } from '../utils/log'; | 32 | import { getLogger } from '../utils/log'; |
@@ -129,6 +130,35 @@ const Service = defineServiceModel(ServiceSettings) | |||
129 | toggleDeveloperTools(): void { | 130 | toggleDeveloperTools(): void { |
130 | self.serviceView?.toggleDeveloperTools(); | 131 | self.serviceView?.toggleDeveloperTools(); |
131 | }, | 132 | }, |
133 | downloadCertificate: flow(function* downloadCertificate( | ||
134 | fingerprint: string, | ||
135 | ) { | ||
136 | const { state } = self; | ||
137 | if (state.type !== 'certificateError') { | ||
138 | log.warn( | ||
139 | 'Tried to save certificate', | ||
140 | fingerprint, | ||
141 | 'when there is no certificate error', | ||
142 | ); | ||
143 | return; | ||
144 | } | ||
145 | let { certificate } = state; | ||
146 | while ( | ||
147 | certificate !== undefined && | ||
148 | certificate.fingerprint !== fingerprint | ||
149 | ) { | ||
150 | certificate = certificate.issuerCert as Certificate; | ||
151 | } | ||
152 | if (certificate === undefined) { | ||
153 | log.warn( | ||
154 | 'Tried to save certificate', | ||
155 | fingerprint, | ||
156 | 'which is not part of the current certificate chain', | ||
157 | ); | ||
158 | return; | ||
159 | } | ||
160 | yield getEnv(self).saveTextFile('certificate.pem', certificate.data); | ||
161 | }), | ||
132 | })) | 162 | })) |
133 | .actions((self) => { | 163 | .actions((self) => { |
134 | function setState(state: ServiceStateSnapshotIn): void { | 164 | function setState(state: ServiceStateSnapshotIn): void { |
@@ -272,6 +302,11 @@ const Service = defineServiceModel(ServiceSettings) | |||
272 | case 'dismiss-all-popups': | 302 | case 'dismiss-all-popups': |
273 | self.dismissAllPopups(); | 303 | self.dismissAllPopups(); |
274 | break; | 304 | break; |
305 | case 'download-certificate': | ||
306 | self.downloadCertificate(action.fingerprint).catch((error) => { | ||
307 | log.error('Error while saving certificate', error); | ||
308 | }); | ||
309 | break; | ||
275 | default: | 310 | default: |
276 | log.error('Unknown action to dispatch', action); | 311 | log.error('Unknown action to dispatch', action); |
277 | break; | 312 | break; |
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) => ({ |
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e3da192..b1d05f8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts | |||
@@ -32,7 +32,10 @@ export { ThemeSource } from './schemas/ThemeSource'; | |||
32 | 32 | ||
33 | export { Translation } from './schemas/Translation'; | 33 | export { Translation } from './schemas/Translation'; |
34 | 34 | ||
35 | export type { CertificateSnapshotIn } from './stores/Certificate'; | 35 | export type { |
36 | CertificatePrincipal, | ||
37 | CertificateSnapshotIn, | ||
38 | } from './stores/Certificate'; | ||
36 | export { default as Certificate } from './stores/Certificate'; | 39 | export { default as Certificate } from './stores/Certificate'; |
37 | 40 | ||
38 | export type { | 41 | export type { |
diff --git a/packages/shared/src/schemas/ServiceAction.ts b/packages/shared/src/schemas/ServiceAction.ts index cfaba08..c0f07d6 100644 --- a/packages/shared/src/schemas/ServiceAction.ts +++ b/packages/shared/src/schemas/ServiceAction.ts | |||
@@ -67,6 +67,10 @@ export const ServiceAction = /* @__PURE__ */ (() => | |||
67 | z.object({ | 67 | z.object({ |
68 | action: z.literal('dismiss-all-popups'), | 68 | action: z.literal('dismiss-all-popups'), |
69 | }), | 69 | }), |
70 | z.object({ | ||
71 | action: z.literal('download-certificate'), | ||
72 | fingerprint: z.string().nonempty(), | ||
73 | }), | ||
70 | ]))(); | 74 | ]))(); |
71 | 75 | ||
72 | /* | 76 | /* |
diff --git a/packages/shared/src/stores/Certificate.ts b/packages/shared/src/stores/Certificate.ts index 8b2d007..363406d 100644 --- a/packages/shared/src/stores/Certificate.ts +++ b/packages/shared/src/stores/Certificate.ts | |||
@@ -30,12 +30,20 @@ const CertificatePrincipal = /* @__PURE__ */ (() => | |||
30 | country: types.string, | 30 | country: types.string, |
31 | }))(); | 31 | }))(); |
32 | 32 | ||
33 | /* | ||
34 | eslint-disable-next-line @typescript-eslint/no-redeclare -- | ||
35 | Intentionally naming the type the same as the store definition. | ||
36 | */ | ||
37 | export interface CertificatePrincipal | ||
38 | extends Instance<typeof CertificatePrincipal> {} | ||
39 | |||
33 | const Certificate = /* @__PURE__ */ (() => | 40 | const Certificate = /* @__PURE__ */ (() => |
34 | types.model('Certificate', { | 41 | types.model('Certificate', { |
35 | data: types.string, | 42 | data: types.string, |
36 | issuer: CertificatePrincipal, | 43 | issuer: CertificatePrincipal, |
37 | issuerName: types.string, | 44 | issuerName: types.string, |
38 | issuerCert: types.maybe(types.late((): IAnyModelType => Certificate)), | 45 | issuerCert: types.maybe(types.late((): IAnyModelType => Certificate)), |
46 | subject: CertificatePrincipal, | ||
39 | subjectName: types.string, | 47 | subjectName: types.string, |
40 | serialNumber: types.string, | 48 | serialNumber: types.string, |
41 | validStart: types.number, | 49 | validStart: types.number, |