create-key-dialog.tsx241 lines · main
1import dayjs from 'dayjs'
2import { useMemo, useState } from 'react'
3import { toast } from 'sonner'
4import {
5 Badge,
6 Button,
7 Checkbox,
8 DialogFooter,
9 DialogHeader,
10 DialogSection,
11 DialogSectionSeparator,
12 DialogTitle,
13 Label,
14 Select,
15 SelectContent,
16 SelectItem,
17 SelectTrigger,
18 SelectValue,
19 Textarea,
20} from 'ui'
21
22import { useJWTSigningKeyCreateMutation } from '@/data/jwt-signing-keys/jwt-signing-key-create-mutation'
23import { JWTAlgorithm } from '@/data/jwt-signing-keys/jwt-signing-keys-query'
24import { stringToBase64URL } from '@/lib/base64url'
25
26const RSA_JWK_REQUIRED_PROPERTIES = ['kty', 'n', 'e', 'p', 'q', 'd', 'dq', 'dp', 'qi']
27const EC_JWK_REQUIRED_PROPERTIES = ['kty', 'crv', 'x', 'y', 'd']
28
29export const CreateKeyDialog = ({
30 projectRef,
31 onClose,
32}: {
33 projectRef: string
34 onClose: () => void
35}) => {
36 const [newKeyAlgorithm, setNewKeyAlgorithm] = useState<JWTAlgorithm>('ES256')
37 const [isBYOK, setBYOK] = useState(false)
38 const [privateKey, setPrivateKey] = useState('')
39 const [isBase64, setBase64] = useState(false)
40
41 const privateKeyMessage = useMemo(() => {
42 const plain = privateKey.replace(/\s+/g, '')
43
44 if (!plain) {
45 return null
46 }
47
48 if (newKeyAlgorithm === 'HS256') {
49 if (privateKey.length < 16) {
50 return 'Secret must be at least 16 letters long'
51 }
52
53 return null
54 }
55
56 let jwk
57 try {
58 jwk = JSON.parse(privateKey)
59 } catch (e: any) {
60 return 'Private key is not valid JSON'
61 }
62
63 if (typeof jwk !== 'object' || !jwk) {
64 return 'Private key must be a JSON object'
65 }
66
67 if (typeof jwk.kty !== 'string' || !jwk.kty) {
68 return 'Private key must have a kty property'
69 }
70
71 if (newKeyAlgorithm === 'RS256') {
72 if (jwk.kty !== 'RSA') {
73 return 'Private key must be of RSA type'
74 }
75
76 if (jwk.e !== 'AQAB') {
77 return 'RSA private keys must use the 65537 (AQAB) public exponent'
78 }
79
80 for (let prop of RSA_JWK_REQUIRED_PROPERTIES) {
81 if (typeof jwk[prop] !== 'string' || !jwk[prop]) {
82 return `Incomplete RSA private key, required properties are: ${RSA_JWK_REQUIRED_PROPERTIES.join(', ')}`
83 }
84 }
85 } else if (newKeyAlgorithm === 'ES256') {
86 if (jwk.kty !== 'EC') {
87 return 'Private key must be of EC type'
88 }
89
90 if (jwk.crv !== 'P-256') {
91 return 'EC private keys must use P-256 curve'
92 }
93
94 for (let prop of EC_JWK_REQUIRED_PROPERTIES) {
95 if (typeof jwk[prop] !== 'string' || !jwk[prop]) {
96 return `Incomplete EC private key, required properties are: ${EC_JWK_REQUIRED_PROPERTIES.join(', ')}`
97 }
98 }
99 }
100
101 return null
102 }, [privateKey, newKeyAlgorithm])
103
104 const { mutate, isPending: isPendingMutation } = useJWTSigningKeyCreateMutation({
105 onSuccess: () => {
106 toast.success('Standby key created successfully')
107 onClose()
108 },
109 onError: (error) => {
110 let errorMessage = error.message
111
112 if (errorMessage.includes('Please wait until')) {
113 const dateString = errorMessage
114 .replace('Please wait until ', '')
115 .replace('before attempting this request again.', '')
116 .trim()
117 const date = dayjs(dateString)
118
119 if (date.isValid()) {
120 errorMessage = `Please wait for ${date.fromNow(true)} before attempting this request again.`
121 }
122 }
123 toast.error(`Failed to add new standby key: ${errorMessage}`)
124 },
125 })
126
127 const handleAddNewStandbyKey = async () => {
128 mutate({
129 projectRef: projectRef!,
130 algorithm: newKeyAlgorithm,
131 status: 'standby',
132 private_jwk: isBYOK
133 ? newKeyAlgorithm === 'HS256'
134 ? {
135 kty: 'oct',
136 k: isBase64
137 ? privateKey
138 .replace(/\s+/g, '')
139 .replace(/\+/g, '-')
140 .replace(/\//g, '_')
141 .replace(/=/g, '')
142 : stringToBase64URL(privateKey),
143 }
144 : JSON.parse(privateKey)
145 : null,
146 })
147 }
148
149 return (
150 <>
151 <DialogHeader>
152 <DialogTitle>Create a new Standby Key</DialogTitle>
153 </DialogHeader>
154 <DialogSectionSeparator />
155 <DialogSection className="space-y-4">
156 <p className="text-sm text-foreground-light">
157 Adds a new JSON Web Token signing key. Once all of your application's components have
158 picked it up you can rotate the current key with it.
159 <br />
160 <br />
161 This action does not invalidate existing tokens, so your users remain signed in.
162 </p>
163 </DialogSection>
164 <DialogSectionSeparator />
165 <DialogSection className="flex flex-col gap-4">
166 <div className="flex flex-col gap-4">
167 <Label htmlFor="algorithm">Choose signing algorithm:</Label>
168 <Select
169 name="algorithm"
170 value={newKeyAlgorithm}
171 onValueChange={(value: JWTAlgorithm) => setNewKeyAlgorithm(value)}
172 >
173 <SelectTrigger id="algorithm">
174 <SelectValue placeholder="Select algorithm" />
175 </SelectTrigger>
176 <SelectContent>
177 <SelectItem value="ES256">
178 <span>ES256 (ECC)</span>
179 <Badge variant="success" className="ml-2">
180 Recommended
181 </Badge>
182 </SelectItem>
183 <SelectItem value="RS256">RS256 (RSA)</SelectItem>
184 <SelectItem value="HS256">HS256 (Shared Secret)</SelectItem>
185 </SelectContent>
186 </Select>
187 </div>
188 <div className="flex flex-col gap-4">
189 <Label htmlFor="byok" className="flex items-center gap-x-2">
190 <Checkbox id="byok" checked={isBYOK} onCheckedChange={(value) => setBYOK(!!value)} />
191 {newKeyAlgorithm === 'HS256'
192 ? 'Import an existing secret'
193 : 'Import an existing private key'}
194 </Label>
195 {isBYOK && (
196 <div className="flex flex-col gap-2">
197 <Textarea
198 className="font-mono"
199 placeholder={
200 newKeyAlgorithm === 'HS256'
201 ? 'Type in your JWT secret'
202 : 'Add a private key in JWK (JSON Web Key) format'
203 }
204 value={privateKey}
205 onChange={(e: any) => {
206 setPrivateKey(e.target.value)
207 }}
208 autoComplete="off"
209 autoCorrect="off"
210 autoCapitalize="off"
211 spellCheck="false"
212 />
213 {privateKeyMessage && <p className="text-red-900 text-sm">{privateKeyMessage}</p>}
214 </div>
215 )}
216 {isBYOK && newKeyAlgorithm === 'HS256' && (
217 <>
218 <Label htmlFor="base64" className="flex items-center gap-x-2">
219 <Checkbox
220 id="base64"
221 checked={isBase64}
222 onCheckedChange={(value) => setBase64(!!value)}
223 />
224 Secret is already Base64 encoded
225 </Label>
226 </>
227 )}
228 </div>
229 </DialogSection>
230 <DialogFooter>
231 <Button
232 onClick={() => handleAddNewStandbyKey()}
233 disabled={isPendingMutation || !!privateKeyMessage}
234 loading={isPendingMutation}
235 >
236 Create standby key
237 </Button>
238 </DialogFooter>
239 </>
240 )
241}