feat(admin): add self-host setup and user management page (#7537)

This commit is contained in:
JimmFly
2024-08-13 14:11:03 +08:00
committed by GitHub
parent dc519348c5
commit ccf225c8f9
47 changed files with 2793 additions and 551 deletions
@@ -0,0 +1,115 @@
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { useCallback } from 'react';
type CreateAdminProps = {
name: string;
email: string;
password: string;
invalidEmail: boolean;
invalidPassword: boolean;
passwordLimits: {
minLength: number;
maxLength: number;
};
onNameChange: (name: string) => void;
onEmailChange: (email: string) => void;
onPasswordChange: (password: string) => void;
};
export const CreateAdmin = ({
name,
email,
password,
invalidEmail,
invalidPassword,
passwordLimits,
onNameChange,
onEmailChange,
onPasswordChange,
}: CreateAdminProps) => {
const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onNameChange(event.target.value);
},
[onNameChange]
);
const handleEmailChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onEmailChange(event.target.value);
},
[onEmailChange]
);
const handlePasswordChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onPasswordChange(event.target.value);
},
[onPasswordChange]
);
return (
<div className="flex flex-col h-full w-full mt-24 max-lg:items-center max-lg:mt-16 max-md:mt-5 lg:pl-0">
<div className="flex flex-col pl-1 max-lg:p-4 max-w-96 mb-5">
<div className="flex flex-col mb-16 max-sm:mb-6">
<h1 className="text-lg font-semibold">
Create Administrator Account
</h1>
<p className="text-sm text-muted-foreground">
This account can also be used to log in as an AFFiNE user.
</p>
</div>
<div className="flex flex-col gap-9">
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
value={name}
onChange={handleNameChange}
required
/>
</div>
<div className="grid gap-2 relative">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={handleEmailChange}
required
/>
<p
className={`absolute text-sm text-red-500 -bottom-6 ${invalidEmail ? '' : 'opacity-0 pointer-events-none'}`}
>
Invalid email address.
</p>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
</div>
<Input
id="password"
type="password"
value={password}
onChange={handlePasswordChange}
min={passwordLimits.minLength}
max={passwordLimits.maxLength}
required
/>
<p
className={`text-sm text-muted-foreground ${invalidPassword && 'text-red-500'}`}
>
{invalidPassword ? 'Invalid password. ' : ''}Please enter{' '}
{String(passwordLimits.minLength)}-
{String(passwordLimits.maxLength)} digit password, it is
recommended to include 2+ of: uppercase, lowercase, numbers,
symbols.
</p>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,222 @@
import { Button } from '@affine/admin/components/ui/button';
import type { CarouselApi } from '@affine/admin/components/ui/carousel';
import {
Carousel,
CarouselContent,
CarouselItem,
} from '@affine/admin/components/ui/carousel';
import { validateEmailAndPassword } from '@affine/admin/utils';
import { useQuery } from '@affine/core/hooks/use-query';
import { serverConfigQuery } from '@affine/graphql';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { CreateAdmin } from './create-admin';
export enum CarouselSteps {
Welcome = 0,
CreateAdmin,
SettingsDone,
}
const Welcome = () => {
return (
<div
className="flex flex-col h-full w-full mt-60 max-lg:items-center max-lg:mt-16"
style={{ minHeight: '300px' }}
>
<h1 className="text-5xl font-extrabold max-lg:text-3xl max-lg:font-bold">
Welcome to AFFiNE
</h1>
<p className="mt-5 font-semibold text-xl max-lg:px-4 max-lg:text-lg">
Configure your Self Host AFFiNE with a few simple settings.
</p>
</div>
);
};
const SettingsDone = () => {
return (
<div
className="flex flex-col h-full w-full mt-60 max-lg:items-center max-lg:mt-16"
style={{ minHeight: '300px' }}
>
<h1 className="text-5xl font-extrabold max-lg:text-3xl max-lg:font-bold">
All Settings Done
</h1>
<p className="mt-5 font-semibold text-xl max-lg:px-4 max-lg:text-lg">
AFFiNE is ready to use.
</p>
</div>
);
};
const CarouselItemElements = {
[CarouselSteps.Welcome]: Welcome,
[CarouselSteps.CreateAdmin]: CreateAdmin,
[CarouselSteps.SettingsDone]: SettingsDone,
};
export const Form = () => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
const navigate = useNavigate();
const [nameValue, setNameValue] = useState('');
const [emailValue, setEmailValue] = useState('');
const [passwordValue, setPasswordValue] = useState('');
const [invalidEmail, setInvalidEmail] = useState(false);
const [invalidPassword, setInvalidPassword] = useState(false);
const { data } = useQuery({
query: serverConfigQuery,
});
const passwordLimits = data.serverConfig.credentialsRequirement.password;
const isCreateAdminStep = current - 1 === CarouselSteps.CreateAdmin;
const disableContinue =
(!nameValue || !emailValue || !passwordValue) && isCreateAdminStep;
useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on('select', () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
const createAdmin = useCallback(async () => {
if (invalidEmail || invalidPassword) return;
try {
const createResponse = await fetch('/api/setup/create-admin-user', {
method: 'POST',
body: JSON.stringify({
email: emailValue,
password: passwordValue,
}),
headers: {
'Content-Type': 'application/json',
},
});
if (!createResponse.ok) {
const errorData = await createResponse.json();
throw new Error(errorData.message || 'Failed to create admin');
}
await createResponse.json();
toast.success('Admin account created successfully.');
} catch (err) {
toast.error((err as Error).message);
console.error(err);
throw err;
}
}, [emailValue, invalidEmail, invalidPassword, passwordValue]);
const onNext = useCallback(async () => {
if (isCreateAdminStep) {
if (
!validateEmailAndPassword(
emailValue,
passwordValue,
passwordLimits,
setInvalidEmail,
setInvalidPassword
)
) {
return;
} else {
try {
await createAdmin();
} catch (e) {
console.error(e);
return;
}
}
}
if (current === count) {
return navigate('/', { replace: true });
}
api?.scrollNext();
}, [
api,
count,
createAdmin,
current,
emailValue,
isCreateAdminStep,
navigate,
passwordLimits,
passwordValue,
]);
const onPrevious = useCallback(() => {
if (current === count) {
return navigate('/admin', { replace: true });
}
api?.scrollPrev();
}, [api, count, current, navigate]);
return (
<div className="flex flex-col justify-between h-full w-full lg:pl-36 max-lg:items-center ">
<Carousel
setApi={setApi}
className=" h-full w-full"
opts={{ watchDrag: false }}
>
<CarouselContent>
{Object.entries(CarouselItemElements).map(([key, Element]) => (
<CarouselItem key={key}>
<Element
name={nameValue}
email={emailValue}
password={passwordValue}
invalidEmail={invalidEmail}
invalidPassword={invalidPassword}
passwordLimits={passwordLimits}
onNameChange={setNameValue}
onEmailChange={setEmailValue}
onPasswordChange={setPasswordValue}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<div>
{current > 1 && (
<Button className="mr-3" onClick={onPrevious} variant="outline">
{current === count ? 'Goto Admin Panel' : 'Back'}
</Button>
)}
<Button onClick={onNext} disabled={disableContinue}>
{current === count ? 'Open AFFiNE' : 'Continue'}
</Button>
</div>
<div className="py-2 px-0 text-sm mt-16 max-lg:mt-5 relative">
{Array.from({ length: count }).map((_, index) => (
<span
key={index}
className={`inline-block w-16 h-1 rounded mr-1 ${
index <= current - 1
? 'bg-primary'
: 'bg-muted-foreground opacity-20'
}`}
/>
))}
</div>
</div>
);
};
@@ -0,0 +1,21 @@
import { Form } from './form';
import logo from './logo.svg';
export function Setup() {
return (
<div className="w-full lg:grid lg:grid-cols-2 h-screen">
<div className="flex items-center justify-center py-12 h-full">
<Form />
</div>
<div className="hidden lg:block relative overflow-hidden ">
<img
src={logo}
alt="Image"
className="absolute object-right-bottom bottom-0 right-0 h-3/4"
/>
</div>
</div>
);
}
export { Setup as Component };
@@ -0,0 +1,5 @@
<svg width="730" height="703" viewBox="0 0 730 703" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M778.049 644.705C764.467 620.365 741.846 579.955 719.499 540.04C712.719 527.929 705.985 515.888 699.526 504.342C686.24 480.591 674.142 458.937 665.514 443.48C605.64 336.671 487.764 125.197 428.872 20.6265C410.725 -8.5204 368.678 -6.21127 353.065 23.9724C335.352 55.664 315.858 90.5131 295.268 127.341C288.74 139.028 282.075 150.928 275.341 162.968C189.559 316.313 89.1678 495.813 17.6524 623.663C14.0002 630.497 7.19787 641.642 3.79672 649.017C-2.161 662.33 -0.99684 678.918 6.51308 691.217C14.9817 705.779 30.8233 713.767 47.0073 712.942C66.4783 712.942 107.771 712.918 161.163 712.942C173.831 712.942 187.185 712.942 201.086 712.942C386.734 712.942 670.33 713.06 739.928 712.942C773.734 713.013 794.94 674.747 778.094 644.681L778.049 644.705ZM378.425 508.89L344.254 447.792C338.251 437.048 345.76 423.617 357.767 423.617H426.11C438.139 423.617 445.649 437.048 439.623 447.792L405.452 508.89C399.448 519.635 384.429 519.635 378.402 508.89H378.425ZM321.176 395.106C318.346 387.661 315.903 380.097 313.895 372.416L426.954 395.106H321.176ZM372.81 555.85C367.993 562.118 362.858 568.079 357.425 573.734L319.921 461.317L372.787 555.85H372.81ZM481.875 429.319C489.522 430.497 497.1 432.123 504.542 434.15L428.986 523.876L481.875 429.319ZM306.933 334.126C305.723 322.769 305.198 311.294 305.243 299.796L454.552 374.984L306.91 334.15L306.933 334.126ZM289.219 446.685L328.755 599.017C319.83 605.779 310.471 612 300.792 617.702L289.196 446.685H289.219ZM540.151 447.085C550.286 451.68 560.17 456.958 569.803 462.755L432.068 558.654L540.151 447.085ZM308.667 251.304C311.407 230.428 315.63 209.857 320.743 190.136L547.57 393.669L308.69 251.304H308.667ZM258.403 638.932C239.526 646.92 220.169 653.423 201.063 658.701L258.403 354.202V638.932ZM608.767 490.04C624.906 502.929 640.062 516.996 654.055 531.416L369.888 632.405L608.767 490.04ZM401.868 75.6922C443.686 150.598 504.405 259.221 562.247 362.614L346.285 139.688C358.977 117.021 371.007 95.5083 382.1 75.6451C386.506 67.7988 397.463 67.7988 401.868 75.6451V75.6922ZM64.5609 643.362C76.4535 622.179 92.7972 593.126 96.4267 586.434C130.849 524.889 177.21 442.019 225.1 356.393L146.006 661.057C117.678 661.057 93.2538 661.057 74.4447 661.057C65.6565 661.057 60.1553 651.232 64.5609 643.385V643.362ZM709.501 661.104C643.555 661.104 515.521 661.104 388.4 661.104L683.57 579.46C699.046 607.122 711.441 629.271 719.385 643.456C723.79 651.302 718.289 661.104 709.523 661.104H709.501Z"
fill="black" fill-opacity="0.05" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB