Skip to content

Commit 2c0e948

Browse files
authored
Merge pull request #1827 from transformerlab/add/startup-wizard
Add startup wizard
2 parents c655a23 + c701dc3 commit 2c0e948

3 files changed

Lines changed: 384 additions & 0 deletions

File tree

src/renderer/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import './styles.css';
1313

1414
import AnnouncementBanner from './components/Shared/AnnouncementBanner';
1515
import InsecurePasswordBanner from './components/Shared/InsecurePasswordBanner';
16+
import StartupWizard from './components/Shared/StartupWizard';
1617
import VersionUpdateBanner from './components/Shared/VersionUpdateBanner';
1718
import { NotificationProvider } from './components/Shared/NotificationSystem';
1819
import { ExperimentInfoProvider } from './lib/ExperimentInfoContext';
@@ -82,6 +83,7 @@ function AppContent({
8283
}}
8384
>
8485
<InsecurePasswordBanner />
86+
<StartupWizard />
8587
<Box
8688
component="main"
8789
className="MainContent"
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
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&apos;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

Comments
 (0)