|
| 1 | +import { useState, useEffect } from 'react'; |
| 2 | +import { useNavigate } from 'react-router-dom'; |
| 3 | +import { |
| 4 | + Modal, |
| 5 | + ModalDialog, |
| 6 | + ModalClose, |
| 7 | + DialogTitle, |
| 8 | + DialogContent, |
| 9 | + Box, |
| 10 | + Button, |
| 11 | + Chip, |
| 12 | + Input, |
| 13 | + FormControl, |
| 14 | + FormLabel, |
| 15 | + Alert, |
| 16 | + Typography, |
| 17 | + Stack, |
| 18 | +} from '@mui/joy'; |
| 19 | +import { useAuth } from 'renderer/lib/authContext'; |
| 20 | +import { API_URL } from 'renderer/lib/api-client/urls'; |
| 21 | + |
| 22 | +const SPECIAL_SECRETS = { |
| 23 | + _HF_TOKEN: 'HuggingFace Token', |
| 24 | + _GITHUB_PAT_TOKEN: 'GitHub Personal Access Token', |
| 25 | + _WANDB_API_KEY: 'Weights & Biases API Key', |
| 26 | + _NGROK_AUTH_TOKEN: 'ngrok Auth Token', |
| 27 | +} as const; |
| 28 | + |
| 29 | +type SecretKey = keyof typeof SPECIAL_SECRETS; |
| 30 | + |
| 31 | +function wizardKey(userId: string): string { |
| 32 | + return `startup_wizard_shown_${userId}`; |
| 33 | +} |
| 34 | + |
| 35 | +function StepIndicator({ current, total }: { current: number; total: number }) { |
| 36 | + return ( |
| 37 | + <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> |
| 38 | + {Array.from({ length: total }, (_, i) => { |
| 39 | + const stepNum = i + 1; |
| 40 | + const done = stepNum < current; |
| 41 | + const active = stepNum === current; |
| 42 | + return ( |
| 43 | + <Box |
| 44 | + key={stepNum} |
| 45 | + sx={{ |
| 46 | + display: 'flex', |
| 47 | + alignItems: 'center', |
| 48 | + gap: 1, |
| 49 | + flex: stepNum < total ? 1 : 'none', |
| 50 | + }} |
| 51 | + > |
| 52 | + <Box |
| 53 | + sx={{ |
| 54 | + width: 24, |
| 55 | + height: 24, |
| 56 | + borderRadius: '50%', |
| 57 | + display: 'flex', |
| 58 | + alignItems: 'center', |
| 59 | + justifyContent: 'center', |
| 60 | + fontSize: '11px', |
| 61 | + fontWeight: 700, |
| 62 | + flexShrink: 0, |
| 63 | + bgcolor: done |
| 64 | + ? 'success.500' |
| 65 | + : active |
| 66 | + ? 'primary.500' |
| 67 | + : 'neutral.700', |
| 68 | + color: done || active ? '#fff' : 'neutral.400', |
| 69 | + }} |
| 70 | + > |
| 71 | + {done ? '✓' : stepNum} |
| 72 | + </Box> |
| 73 | + {stepNum < total && ( |
| 74 | + <Box |
| 75 | + sx={{ |
| 76 | + flex: 1, |
| 77 | + height: '2px', |
| 78 | + bgcolor: done ? 'success.500' : 'neutral.outlinedBorder', |
| 79 | + borderRadius: 1, |
| 80 | + }} |
| 81 | + /> |
| 82 | + )} |
| 83 | + </Box> |
| 84 | + ); |
| 85 | + })} |
| 86 | + </Box> |
| 87 | + ); |
| 88 | +} |
| 89 | + |
| 90 | +type ExistingSecrets = Partial< |
| 91 | + Record<SecretKey, { exists: boolean; masked_value: string | null }> |
| 92 | +>; |
| 93 | + |
| 94 | +function Step1({ |
| 95 | + secrets, |
| 96 | + setSecrets, |
| 97 | + hasTeamSecrets, |
| 98 | + existingUserSecrets, |
| 99 | +}: { |
| 100 | + secrets: Partial<Record<SecretKey, string>>; |
| 101 | + setSecrets: React.Dispatch< |
| 102 | + React.SetStateAction<Partial<Record<SecretKey, string>>> |
| 103 | + >; |
| 104 | + hasTeamSecrets: boolean; |
| 105 | + existingUserSecrets: ExistingSecrets; |
| 106 | +}) { |
| 107 | + return ( |
| 108 | + <Stack gap={1.5}> |
| 109 | + <Typography level="body-sm" color="neutral"> |
| 110 | + Add tokens for external services. Fill in whichever ones you use — you |
| 111 | + can always update these later in User Settings. |
| 112 | + </Typography> |
| 113 | + {hasTeamSecrets && ( |
| 114 | + <Alert color="primary" variant="soft"> |
| 115 | + Some secrets are already configured at the team level — you can skip |
| 116 | + this step. |
| 117 | + </Alert> |
| 118 | + )} |
| 119 | + {(Object.entries(SPECIAL_SECRETS) as [SecretKey, string][]).map( |
| 120 | + ([key, label]) => { |
| 121 | + const existing = existingUserSecrets[key]; |
| 122 | + const isSet = existing?.exists === true; |
| 123 | + return ( |
| 124 | + <FormControl key={key}> |
| 125 | + <Stack direction="row" alignItems="center" gap={1}> |
| 126 | + <FormLabel sx={{ mb: 0 }}>{label}</FormLabel> |
| 127 | + {isSet && ( |
| 128 | + <Chip size="sm" color="success" variant="soft"> |
| 129 | + Already set ···{existing?.masked_value} |
| 130 | + </Chip> |
| 131 | + )} |
| 132 | + </Stack> |
| 133 | + <Input |
| 134 | + type="password" |
| 135 | + placeholder={isSet ? 'Enter new value to update' : label} |
| 136 | + value={secrets[key] ?? ''} |
| 137 | + onChange={(e) => |
| 138 | + setSecrets((prev) => ({ ...prev, [key]: e.target.value })) |
| 139 | + } |
| 140 | + slotProps={{ input: { autoComplete: 'new-password' } }} |
| 141 | + /> |
| 142 | + </FormControl> |
| 143 | + ); |
| 144 | + }, |
| 145 | + )} |
| 146 | + </Stack> |
| 147 | + ); |
| 148 | +} |
| 149 | + |
| 150 | +function Step2({ |
| 151 | + isDefaultPassword, |
| 152 | + onNavigate, |
| 153 | +}: { |
| 154 | + isDefaultPassword: boolean; |
| 155 | + onNavigate: (path: string) => void; |
| 156 | +}) { |
| 157 | + return ( |
| 158 | + <Stack gap={2}> |
| 159 | + <Typography level="body-sm" color="neutral"> |
| 160 | + A few more things — these can be done anytime. We just want to make sure |
| 161 | + you know about them. |
| 162 | + </Typography> |
| 163 | + |
| 164 | + <Box |
| 165 | + sx={{ |
| 166 | + border: '1px solid', |
| 167 | + borderColor: 'primary.outlinedBorder', |
| 168 | + borderRadius: 'md', |
| 169 | + p: 2, |
| 170 | + bgcolor: 'primary.softBg', |
| 171 | + }} |
| 172 | + > |
| 173 | + <Typography level="title-sm" mb={0.5}> |
| 174 | + 🖥️ Add a Compute Provider |
| 175 | + </Typography> |
| 176 | + <Typography level="body-sm" color="neutral" mb={1}> |
| 177 | + Connect a SLURM cluster or cloud GPU provider to run training jobs |
| 178 | + beyond your local machine. |
| 179 | + </Typography> |
| 180 | + <Button |
| 181 | + size="sm" |
| 182 | + variant="soft" |
| 183 | + color="primary" |
| 184 | + onClick={() => onNavigate('/compute')} |
| 185 | + > |
| 186 | + Open Compute Settings |
| 187 | + </Button> |
| 188 | + </Box> |
| 189 | + |
| 190 | + {isDefaultPassword ? ( |
| 191 | + <Box |
| 192 | + sx={{ |
| 193 | + border: '1px solid', |
| 194 | + borderColor: 'danger.outlinedBorder', |
| 195 | + borderRadius: 'md', |
| 196 | + p: 2, |
| 197 | + bgcolor: 'danger.softBg', |
| 198 | + }} |
| 199 | + > |
| 200 | + <Typography level="title-sm" color="danger" mb={0.5}> |
| 201 | + ⚠️ Insecure Password |
| 202 | + </Typography> |
| 203 | + <Typography level="body-sm" color="neutral" mb={1}> |
| 204 | + You are still using the default password. Change it to keep your |
| 205 | + account secure. |
| 206 | + </Typography> |
| 207 | + <Button |
| 208 | + size="sm" |
| 209 | + variant="soft" |
| 210 | + color="danger" |
| 211 | + onClick={() => onNavigate('/user/profile')} |
| 212 | + > |
| 213 | + Open User Settings |
| 214 | + </Button> |
| 215 | + </Box> |
| 216 | + ) : ( |
| 217 | + <Box |
| 218 | + sx={{ |
| 219 | + border: '1px solid', |
| 220 | + borderColor: 'success.outlinedBorder', |
| 221 | + borderRadius: 'md', |
| 222 | + p: 2, |
| 223 | + bgcolor: 'success.softBg', |
| 224 | + }} |
| 225 | + > |
| 226 | + <Typography level="title-sm" color="success" mb={0.5}> |
| 227 | + ✅ Password is Secure |
| 228 | + </Typography> |
| 229 | + <Typography level="body-sm" color="neutral"> |
| 230 | + Your password has been changed from the default. You're good to |
| 231 | + go. |
| 232 | + </Typography> |
| 233 | + </Box> |
| 234 | + )} |
| 235 | + </Stack> |
| 236 | + ); |
| 237 | +} |
| 238 | + |
| 239 | +export default function StartupWizard() { |
| 240 | + const { user, isDefaultPassword, fetchWithAuth, team } = useAuth(); |
| 241 | + const navigate = useNavigate(); |
| 242 | + const [open, setOpen] = useState(false); |
| 243 | + const [step, setStep] = useState(1); |
| 244 | + const [secrets, setSecrets] = useState<Partial<Record<SecretKey, string>>>( |
| 245 | + {}, |
| 246 | + ); |
| 247 | + const [saving, setSaving] = useState(false); |
| 248 | + const [hasTeamSecrets, setHasTeamSecrets] = useState(false); |
| 249 | + const [existingUserSecrets, setExistingUserSecrets] = |
| 250 | + useState<ExistingSecrets>({}); |
| 251 | + |
| 252 | + useEffect(() => { |
| 253 | + if (!user?.id) return; |
| 254 | + if (!localStorage.getItem(wizardKey(user.id))) { |
| 255 | + setOpen(true); |
| 256 | + } |
| 257 | + }, [user?.id]); |
| 258 | + |
| 259 | + useEffect(() => { |
| 260 | + if (!open) return; |
| 261 | + if (team?.id) { |
| 262 | + fetchWithAuth(`${API_URL()}teams/${team.id}/special_secrets`) |
| 263 | + .then((res) => (res.ok ? res.json() : { special_secrets: {} })) |
| 264 | + .then((data) => { |
| 265 | + const anyExists = Object.values(data.special_secrets ?? {}).some( |
| 266 | + (s: any) => s.exists, |
| 267 | + ); |
| 268 | + setHasTeamSecrets(anyExists); |
| 269 | + }) |
| 270 | + .catch(() => {}); |
| 271 | + } |
| 272 | + fetchWithAuth(`${API_URL()}users/me/special_secrets`) |
| 273 | + .then((res) => (res.ok ? res.json() : { special_secrets: {} })) |
| 274 | + .then((data) => setExistingUserSecrets(data.special_secrets ?? {})) |
| 275 | + .catch(() => {}); |
| 276 | + }, [open, team?.id, fetchWithAuth]); |
| 277 | + |
| 278 | + const dismiss = () => { |
| 279 | + if (user?.id) localStorage.setItem(wizardKey(user.id), 'shown'); |
| 280 | + setOpen(false); |
| 281 | + }; |
| 282 | + |
| 283 | + const handleNext = async () => { |
| 284 | + setSaving(true); |
| 285 | + try { |
| 286 | + for (const [key, value] of Object.entries(secrets) as [ |
| 287 | + SecretKey, |
| 288 | + string, |
| 289 | + ][]) { |
| 290 | + if (value?.trim()) { |
| 291 | + await fetchWithAuth(`${API_URL()}users/me/special_secrets`, { |
| 292 | + method: 'PUT', |
| 293 | + headers: { 'Content-Type': 'application/json' }, |
| 294 | + body: JSON.stringify({ secret_type: key, value: value.trim() }), |
| 295 | + }); |
| 296 | + } |
| 297 | + } |
| 298 | + } finally { |
| 299 | + setSaving(false); |
| 300 | + } |
| 301 | + setStep(2); |
| 302 | + }; |
| 303 | + |
| 304 | + const handleNavigate = (path: string) => { |
| 305 | + dismiss(); |
| 306 | + navigate(path); |
| 307 | + }; |
| 308 | + |
| 309 | + if (!open) return null; |
| 310 | + |
| 311 | + return ( |
| 312 | + <Modal open={open} onClose={dismiss}> |
| 313 | + <ModalDialog sx={{ minWidth: 480, maxWidth: 560 }}> |
| 314 | + <ModalClose /> |
| 315 | + <DialogTitle>Welcome to Transformer Lab</DialogTitle> |
| 316 | + <DialogContent sx={{ pt: 2 }}> |
| 317 | + <StepIndicator current={step} total={2} /> |
| 318 | + {step === 1 && ( |
| 319 | + <Step1 |
| 320 | + secrets={secrets} |
| 321 | + setSecrets={setSecrets} |
| 322 | + hasTeamSecrets={hasTeamSecrets} |
| 323 | + existingUserSecrets={existingUserSecrets} |
| 324 | + /> |
| 325 | + )} |
| 326 | + {step === 2 && ( |
| 327 | + <Step2 |
| 328 | + isDefaultPassword={isDefaultPassword} |
| 329 | + onNavigate={handleNavigate} |
| 330 | + /> |
| 331 | + )} |
| 332 | + </DialogContent> |
| 333 | + <Box |
| 334 | + sx={{ |
| 335 | + display: 'flex', |
| 336 | + justifyContent: 'space-between', |
| 337 | + px: 2, |
| 338 | + pb: 2, |
| 339 | + }} |
| 340 | + > |
| 341 | + {step === 1 && ( |
| 342 | + <> |
| 343 | + <Button |
| 344 | + variant="plain" |
| 345 | + color="neutral" |
| 346 | + onClick={() => setStep(2)} |
| 347 | + > |
| 348 | + Skip |
| 349 | + </Button> |
| 350 | + <Button loading={saving} onClick={handleNext}> |
| 351 | + Next |
| 352 | + </Button> |
| 353 | + </> |
| 354 | + )} |
| 355 | + {step === 2 && ( |
| 356 | + <Button sx={{ ml: 'auto' }} onClick={dismiss}> |
| 357 | + Done |
| 358 | + </Button> |
| 359 | + )} |
| 360 | + </Box> |
| 361 | + </ModalDialog> |
| 362 | + </Modal> |
| 363 | + ); |
| 364 | +} |
0 commit comments