diff options
Diffstat (limited to 'packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx')
-rw-r--r-- | packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx | 371 |
1 files changed, 371 insertions, 0 deletions
diff --git a/packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx b/packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx new file mode 100644 index 0000000..0cbcac4 --- /dev/null +++ b/packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx | |||
@@ -0,0 +1,371 @@ | |||
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 | fontWeight: theme.typography.fontWeightBold, | ||
44 | })); | ||
45 | |||
46 | const SubHeader = styled(Header)(({ theme }) => ({ | ||
47 | color: theme.palette.text.secondary, | ||
48 | fontWeight: theme.typography.fontWeightMedium, | ||
49 | })); | ||
50 | |||
51 | const Cell = styled('td')({ | ||
52 | verticalAlign: 'top', | ||
53 | }); | ||
54 | |||
55 | function Section({ id, label }: { id: string; label: string }): JSX.Element { | ||
56 | return ( | ||
57 | <tr> | ||
58 | <SectionHeader id={id} scope="colgroup"> | ||
59 | {label} | ||
60 | </SectionHeader> | ||
61 | <Cell aria-hidden="true" /> | ||
62 | </tr> | ||
63 | ); | ||
64 | } | ||
65 | |||
66 | function Row({ | ||
67 | id, | ||
68 | label, | ||
69 | headers, | ||
70 | children, | ||
71 | }: { | ||
72 | id: string; | ||
73 | label: string; | ||
74 | headers: string; | ||
75 | children: ReactNode; | ||
76 | }): JSX.Element { | ||
77 | return ( | ||
78 | <tr> | ||
79 | <SubHeader id={id} scope="row"> | ||
80 | {label} | ||
81 | </SubHeader> | ||
82 | <Cell headers={`${headers} ${id}`}>{children}</Cell> | ||
83 | </tr> | ||
84 | ); | ||
85 | } | ||
86 | |||
87 | function OptionalRow({ | ||
88 | id, | ||
89 | label, | ||
90 | value, | ||
91 | headers, | ||
92 | }: { | ||
93 | id: string; | ||
94 | label: string; | ||
95 | value: string; | ||
96 | headers: string; | ||
97 | }): JSX.Element | null { | ||
98 | if (value === '') { | ||
99 | // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering. | ||
100 | return null; | ||
101 | } | ||
102 | |||
103 | return ( | ||
104 | <Row id={id} label={label} headers={headers}> | ||
105 | {value} | ||
106 | </Row> | ||
107 | ); | ||
108 | } | ||
109 | |||
110 | function DateTimeRow({ | ||
111 | id, | ||
112 | label, | ||
113 | value, | ||
114 | locales, | ||
115 | headers, | ||
116 | }: { | ||
117 | id: string; | ||
118 | label: string; | ||
119 | value: number; | ||
120 | locales: readonly string[]; | ||
121 | headers: string; | ||
122 | }): JSX.Element { | ||
123 | // Electron provides `validStart` and `validExpiry` in seconds, | ||
124 | // but `Date` expects milliseconds. | ||
125 | const date = new Date(value * 1000); | ||
126 | const dateStr = new Intl.DateTimeFormat([...locales], { | ||
127 | dateStyle: 'long', | ||
128 | timeStyle: 'long', | ||
129 | }).format(date); | ||
130 | |||
131 | return ( | ||
132 | <Row id={id} label={label} headers={headers}> | ||
133 | {dateStr} | ||
134 | </Row> | ||
135 | ); | ||
136 | } | ||
137 | |||
138 | function Principal({ | ||
139 | id, | ||
140 | label, | ||
141 | principal: { | ||
142 | country, | ||
143 | state, | ||
144 | locality, | ||
145 | organizations, | ||
146 | organizationUnits, | ||
147 | commonName, | ||
148 | }, | ||
149 | t, | ||
150 | onIssuerClick, | ||
151 | }: { | ||
152 | id: string; | ||
153 | label: string; | ||
154 | principal: CertificatePrincipal; | ||
155 | t: TFunction; | ||
156 | onIssuerClick?: (() => void) | undefined; | ||
157 | }): JSX.Element { | ||
158 | return ( | ||
159 | <> | ||
160 | <Section id={id} label={label} /> | ||
161 | <OptionalRow | ||
162 | id={`${id}-country`} | ||
163 | label={t<string>('country')} | ||
164 | value={country} | ||
165 | headers={id} | ||
166 | /> | ||
167 | <OptionalRow | ||
168 | id={`${id}-state`} | ||
169 | label={t<string>('state')} | ||
170 | value={state} | ||
171 | headers={id} | ||
172 | /> | ||
173 | <OptionalRow | ||
174 | id={`${id}-locality`} | ||
175 | label={t<string>('locality')} | ||
176 | value={locality} | ||
177 | headers={id} | ||
178 | /> | ||
179 | {/* | ||
180 | eslint-disable react/no-array-index-key -- | ||
181 | These entries can freely contain repetitions, so we can only tell them apart by index. | ||
182 | */} | ||
183 | {organizations.map((value, i) => ( | ||
184 | <OptionalRow | ||
185 | key={i} | ||
186 | id={`${id}-organization-${i}`} | ||
187 | label={t<string>('organization')} | ||
188 | value={value} | ||
189 | headers={id} | ||
190 | /> | ||
191 | ))} | ||
192 | {organizationUnits.map((value, i) => ( | ||
193 | <OptionalRow | ||
194 | key={i} | ||
195 | id={`${id}-organizationUnit-${i}`} | ||
196 | label={t<string>('organizationUnit')} | ||
197 | value={value} | ||
198 | headers={id} | ||
199 | /> | ||
200 | ))} | ||
201 | {/* eslint-enable react/no-array-index-key */} | ||
202 | {commonName !== '' && ( | ||
203 | <Row | ||
204 | id={`${id}-commonName`} | ||
205 | label={t<string>('commonName')} | ||
206 | headers={id} | ||
207 | > | ||
208 | {onIssuerClick === undefined ? ( | ||
209 | commonName | ||
210 | ) : ( | ||
211 | // eslint-disable-next-line jsx-a11y/anchor-is-valid -- This is a `button`. | ||
212 | <Link | ||
213 | component="button" | ||
214 | variant="body1" | ||
215 | onClick={onIssuerClick} | ||
216 | sx={{ verticalAlign: 'baseline' }} | ||
217 | > | ||
218 | {commonName} | ||
219 | </Link> | ||
220 | )} | ||
221 | </Row> | ||
222 | )} | ||
223 | </> | ||
224 | ); | ||
225 | } | ||
226 | |||
227 | Principal.defaultProps = { | ||
228 | onIssuerClick: undefined, | ||
229 | }; | ||
230 | |||
231 | function parseFingerprint(fingerprint: string): { | ||
232 | algorithm: string | undefined; | ||
233 | formattedValue: string; | ||
234 | } { | ||
235 | const separatorIndex = fingerprint.indexOf('/'); | ||
236 | if (separatorIndex <= 0) { | ||
237 | return { | ||
238 | algorithm: undefined, | ||
239 | formattedValue: fingerprint, | ||
240 | }; | ||
241 | } | ||
242 | return { | ||
243 | algorithm: fingerprint.slice(0, separatorIndex).toUpperCase(), | ||
244 | formattedValue: fingerprint.slice(separatorIndex + 1), | ||
245 | }; | ||
246 | } | ||
247 | |||
248 | function Fingerprint({ | ||
249 | id, | ||
250 | label, | ||
251 | fallbackAlgorithmLabel, | ||
252 | value, | ||
253 | }: { | ||
254 | id: string; | ||
255 | label: string; | ||
256 | fallbackAlgorithmLabel: string; | ||
257 | value: string; | ||
258 | }) { | ||
259 | const { algorithm, formattedValue } = parseFingerprint(value); | ||
260 | return ( | ||
261 | <> | ||
262 | <Section id={id} label={label} /> | ||
263 | <OptionalRow | ||
264 | id={`${id}-fingerprint`} | ||
265 | label={algorithm ?? fallbackAlgorithmLabel} | ||
266 | headers={id} | ||
267 | value={formattedValue} | ||
268 | /> | ||
269 | </> | ||
270 | ); | ||
271 | } | ||
272 | |||
273 | function SingleCertificateDetails({ | ||
274 | detailsId, | ||
275 | certificate: { | ||
276 | subject, | ||
277 | issuer, | ||
278 | validStart, | ||
279 | validExpiry, | ||
280 | fingerprint, | ||
281 | serialNumber, | ||
282 | }, | ||
283 | onIssuerClick, | ||
284 | onDownloadClick, | ||
285 | }: { | ||
286 | detailsId: string; | ||
287 | certificate: Certificate; | ||
288 | onIssuerClick?: (() => void) | undefined; | ||
289 | onDownloadClick: () => void; | ||
290 | }): JSX.Element { | ||
291 | const { | ||
292 | t, | ||
293 | i18n: { languages }, | ||
294 | } = useTranslation(undefined, { | ||
295 | keyPrefix: 'error.certificateError.details', | ||
296 | }); | ||
297 | |||
298 | const id = `${detailsId}-${fingerprint}-certificate`; | ||
299 | |||
300 | return ( | ||
301 | <SingleCertificateDetailsRoot> | ||
302 | <tbody> | ||
303 | <Principal | ||
304 | id={`${id}-subject`} | ||
305 | label={t<string>('subject')} | ||
306 | principal={subject} | ||
307 | t={t} | ||
308 | /> | ||
309 | <Principal | ||
310 | id={`${id}-issuer`} | ||
311 | label={t<string>('issuer')} | ||
312 | principal={issuer} | ||
313 | t={t} | ||
314 | onIssuerClick={onIssuerClick} | ||
315 | /> | ||
316 | <Section id={`${id}-validity`} label={t<string>('validity')} /> | ||
317 | <DateTimeRow | ||
318 | id={`${id}-validity-validStart`} | ||
319 | label={t<string>('validStart')} | ||
320 | value={validStart} | ||
321 | locales={languages} | ||
322 | headers={`${id}-validity`} | ||
323 | /> | ||
324 | <DateTimeRow | ||
325 | id={`${id}-validity-validExpiry`} | ||
326 | label={t<string>('validExpiry')} | ||
327 | value={validExpiry} | ||
328 | locales={languages} | ||
329 | headers={`${id}-validity`} | ||
330 | /> | ||
331 | <Section | ||
332 | id={`${id}-miscellaneous`} | ||
333 | label={t<string>('miscellaneous')} | ||
334 | /> | ||
335 | <OptionalRow | ||
336 | id={`${id}-miscellaneous-serialNumber`} | ||
337 | label={t<string>('serialNumber')} | ||
338 | value={serialNumber} | ||
339 | headers={`${id}-miscellaneous`} | ||
340 | /> | ||
341 | <Row | ||
342 | id={`${id}-miscellaneous-download`} | ||
343 | label={t<string>('download')} | ||
344 | headers={`${id}-miscellaneous`} | ||
345 | > | ||
346 | {/* eslint-disable-next-line jsx-a11y/anchor-is-valid -- This is a `button`. */} | ||
347 | <Link | ||
348 | component="button" | ||
349 | variant="body1" | ||
350 | onClick={onDownloadClick} | ||
351 | sx={{ verticalAlign: 'baseline' }} | ||
352 | > | ||
353 | <AttachmentIcon fontSize="inherit" /> {t('downloadPEM')} | ||
354 | </Link> | ||
355 | </Row> | ||
356 | <Fingerprint | ||
357 | id={`${id}-fingerprint`} | ||
358 | label={t<string>('fingerprint')} | ||
359 | fallbackAlgorithmLabel={t<string>('fingerprintUnknown')} | ||
360 | value={fingerprint} | ||
361 | /> | ||
362 | </tbody> | ||
363 | </SingleCertificateDetailsRoot> | ||
364 | ); | ||
365 | } | ||
366 | |||
367 | SingleCertificateDetails.defaultProps = { | ||
368 | onIssuerClick: undefined, | ||
369 | }; | ||
370 | |||
371 | export default observer(SingleCertificateDetails); | ||