diff options
Diffstat (limited to 'packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx')
-rw-r--r-- | packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx | 369 |
1 files changed, 369 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..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); | ||