added init page
This commit is contained in:
@@ -5,7 +5,7 @@ import './app.css';
|
|||||||
import AppTheme from './components/theme';
|
import AppTheme from './components/theme';
|
||||||
import { ApiProvider } from './providers/ApiProvider';
|
import { ApiProvider } from './providers/ApiProvider';
|
||||||
import { LayoutProvider } from './providers/LayoutProvider';
|
import { LayoutProvider } from './providers/LayoutProvider';
|
||||||
// import { LayoutContainer } from './routes/layout';
|
import { Tooltip } from 'radix-ui';
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [];
|
export const links: Route.LinksFunction = () => [];
|
||||||
|
|
||||||
@@ -31,9 +31,11 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<AppTheme>
|
<AppTheme>
|
||||||
<ApiProvider>
|
<ApiProvider>
|
||||||
|
<Tooltip.Provider delayDuration={250}>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
|
</Tooltip.Provider>
|
||||||
</ApiProvider>
|
</ApiProvider>
|
||||||
</AppTheme>
|
</AppTheme>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { type RouteConfig, index, layout, route } from '@react-router/dev/routes
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
route('login', 'routes/auth/login.tsx'),
|
route('login', 'routes/auth/login.tsx'),
|
||||||
|
route('init', 'routes/init.tsx'),
|
||||||
layout('routes/layout.tsx', [index('routes/home.tsx')]),
|
layout('routes/layout.tsx', [index('routes/home.tsx')]),
|
||||||
// route('init', 'routes/init.tsx'),
|
|
||||||
// catch-all 404 route
|
// catch-all 404 route
|
||||||
route('*', 'routes/404.tsx'),
|
route('*', 'routes/404.tsx'),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
170
apps/frontend/app/routes/init.tsx
Normal file
170
apps/frontend/app/routes/init.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { Box, Container, Flex, Heading, Text } from '@radix-ui/themes';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { Slide, toast, ToastContainer } from 'react-toastify';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import { useResponseErrorHandler } from '../hooks/ResponseHelper';
|
||||||
|
import { useApi } from '../providers/ApiProvider';
|
||||||
|
import { formHook } from '../providers/FormProvider';
|
||||||
|
import { TooltipContentContainer } from '../components/info';
|
||||||
|
import { SearchParamKeys } from '../lib/constants';
|
||||||
|
|
||||||
|
const initFormSchema = v.object({
|
||||||
|
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
|
||||||
|
password: v.pipe(v.string(), v.minLength(1, 'Password is required')),
|
||||||
|
setup_secret: v.pipe(v.string(), v.minLength(1, 'Setup secret is required')),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function InitRoute() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { tanstackApiClient } = useApi();
|
||||||
|
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
||||||
|
|
||||||
|
const { mutateAsync: initAdmin, isPending } = useMutation({
|
||||||
|
...tanstackApiClient.mutation('post', '/api/auth/init_admin').mutationOptions,
|
||||||
|
onSuccess: async () => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set(SearchParamKeys.Message, 'Initialization successful. Please log in.');
|
||||||
|
navigate(`/login?${searchParams.toString()}`);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (defaultResponseErrorHandler(error)) return;
|
||||||
|
console.error('Init failed:', error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { queryOptions: healthInfoQuery } = tanstackApiClient.get('/api/health/info');
|
||||||
|
useQuery({
|
||||||
|
...healthInfoQuery,
|
||||||
|
queryFn: async (...args) => {
|
||||||
|
try {
|
||||||
|
const data = await healthInfoQuery.queryFn!(...args);
|
||||||
|
if (data.is_initialized) {
|
||||||
|
navigate('/');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (defaultResponseErrorHandler(error)) return {} as never;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = formHook.useAppForm({
|
||||||
|
defaultValues: { username: '', password: '', setup_secret: '' },
|
||||||
|
validators: { onBlur: initFormSchema, onSubmit: initFormSchema },
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
toast.dismiss();
|
||||||
|
return await initAdmin({ body: { username: value.username, password: value.password, setup_secret: value.setup_secret } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex align="center" justify="center" style={{ minHeight: 'calc(100vh - 64px)' }}>
|
||||||
|
<Container size="3" p="0">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxWidth: 480,
|
||||||
|
margin: '40px auto',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 6px 18px rgba(15,23,42,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Heading size="6" style={{ marginBottom: 12, alignSelf: 'center' }}>
|
||||||
|
Initialize YANPM
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Heading size="3" style={{ marginBottom: 24, color: 'var(--gray-11)', alignSelf: 'center' }}>
|
||||||
|
Create the initial admin user
|
||||||
|
</Heading>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form.AppField
|
||||||
|
name="username"
|
||||||
|
children={(field) => (
|
||||||
|
<>
|
||||||
|
<field.TextField
|
||||||
|
label="Username"
|
||||||
|
value={field.state.value}
|
||||||
|
autoComplete="username"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<field.TextFieldErrorMessage {...field.state.meta} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form.AppField
|
||||||
|
name="password"
|
||||||
|
children={(field) => (
|
||||||
|
<>
|
||||||
|
<field.TextField
|
||||||
|
label="Password"
|
||||||
|
value={field.state.value}
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
showPasswordToggle
|
||||||
|
/>
|
||||||
|
<field.TextFieldErrorMessage {...field.state.meta} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form.AppField
|
||||||
|
name="setup_secret"
|
||||||
|
children={(field) => (
|
||||||
|
<>
|
||||||
|
<field.TextField
|
||||||
|
label="Setup Secret"
|
||||||
|
value={field.state.value}
|
||||||
|
required
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
infoIconProps={{
|
||||||
|
children: (
|
||||||
|
<TooltipContentContainer>
|
||||||
|
<Text>This secret is provided when the API server is first started. Refer to your server logs to find it.</Text>
|
||||||
|
</TooltipContentContainer>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<field.TextFieldErrorMessage {...field.state.meta} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 18, display: 'flex', gap: 8, justifySelf: 'center' }}>
|
||||||
|
<form.SubmitButton loading={isPending} label={{ default: 'Initialize' }} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<ToastContainer
|
||||||
|
position="top-center"
|
||||||
|
autoClose={false}
|
||||||
|
newestOnTop={false}
|
||||||
|
closeOnClick
|
||||||
|
rtl={false}
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable={false}
|
||||||
|
theme="colored"
|
||||||
|
transition={Slide}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user