Files
AFFiNE-Mirror/packages/frontend/component/src/components/auth-components/onboarding-page.tsx
liuyi fb3a0e7b8f refactor(server): auth (#5895)
Remove `next-auth` and implement our own Authorization/Authentication system from scratch.

## Server

- [x] tokens
  - [x] function
  - [x] encryption

- [x] AuthController
  - [x] /api/auth/sign-in
  - [x] /api/auth/sign-out
  - [x] /api/auth/session
  - [x] /api/auth/session (WE SUPPORT MULTI-ACCOUNT!)

- [x] OAuthPlugin
  - [x] OAuthController
  - [x] /oauth/login
  - [x] /oauth/callback
  - [x] Providers
    - [x] Google
    - [x] GitHub

## Client

- [x] useSession
- [x] cloudSignIn
- [x] cloudSignOut

## NOTE:

Tests will be adding in the future
2024-03-12 10:00:09 +00:00

298 lines
9.0 KiB
TypeScript

import { apis } from '@affine/electron-api';
import { fetchWithTraceReport } from '@affine/graphql';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { type Location, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import { Button } from '../../ui/button';
import { Checkbox } from '../../ui/checkbox';
import { Divider } from '../../ui/divider';
import Input from '../../ui/input';
import { ScrollableContainer } from '../../ui/scrollbar';
import * as styles from './onboarding-page.css';
import type { User } from './type';
type QuestionOption = {
type: 'checkbox' | 'input';
label: string;
value: string;
};
type Question = {
id?: string;
question: string;
options?: QuestionOption[];
};
type QuestionnaireAnswer = {
form: string;
ask: string;
answer: string[];
};
function getCallbackUrl(location: Location) {
try {
const url =
location.state?.callbackURL ||
new URLSearchParams(location.search).get('redirect_uri');
if (typeof url === 'string' && url) {
if (!url.startsWith('http:') && !url.startsWith('https:')) {
return url;
}
// we will ignore host to avoid redirect hack
const parsedUrl = new URL(url);
return parsedUrl.pathname + parsedUrl.search;
}
} catch (_) {}
return null;
}
export const ScrollableLayout = ({
headerItems,
children,
isMacosDesktop,
isWindowsDesktop,
}: {
isMacosDesktop?: boolean;
isWindowsDesktop?: boolean;
headerItems?: React.ReactNode;
children: React.ReactNode;
}) => {
return (
<div className={styles.layout} data-is-macos-electron={isMacosDesktop}>
<header
className={styles.header}
data-is-windows-electron={isWindowsDesktop}
>
{headerItems}
</header>
<ScrollableContainer className={styles.scrollableContainer}>
<div className={styles.onboardingContainer}>{children}</div>
</ScrollableContainer>
<footer className={styles.footer}>
<div className={styles.linkGroup}>
<a
className={styles.link}
href="https://affine.pro/terms"
target="_blank"
rel="noreferrer"
>
Terms of Conditions
</a>
<Divider orientation="vertical" />
<a
className={styles.link}
href="https://affine.pro/privacy"
target="_blank"
rel="noreferrer"
>
Privacy Policy
</a>
</div>
</footer>
</div>
);
};
export const OnboardingPage = ({
user,
onOpenAffine,
windowControl,
}: {
user: User;
onOpenAffine: () => void;
windowControl?: React.ReactNode;
}) => {
const location = useLocation();
const navigate = useNavigate();
const [questionIdx, setQuestionIdx] = useState(0);
const { data: questions } = useSWR<Question[]>(
'/api/worker/questionnaire',
url => fetchWithTraceReport(url).then(r => r.json()),
{ suspense: true, revalidateOnFocus: false }
);
const [options, setOptions] = useState(new Set<string>());
const [inputs, setInputs] = useState<Record<string, string>>({});
const callbackUrl = useMemo(() => getCallbackUrl(location), [location]);
const question = useMemo(
() => questions?.[questionIdx],
[questionIdx, questions]
);
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
useEffect(() => {
if (environment.isDesktop) {
// to hide macOS window control buttons
apis?.ui.handleSidebarVisibilityChange(false).catch(err => {
console.error(err);
});
}
}, []);
if (!questions) {
return null;
}
if (callbackUrl?.startsWith('/open-app/signin-redirect')) {
const url = new URL(callbackUrl, window.location.origin);
url.searchParams.set('next', 'onboarding');
console.log('redirect to', url.toString());
window.location.assign(url.toString());
return null;
}
if (question) {
return (
<ScrollableLayout
headerItems={
<>
{isWindowsDesktop ? windowControl : null}
<Button
className={clsx(styles.button, {
[styles.disableButton]: questionIdx === 0,
[styles.windowsAppButton]: isWindowsDesktop,
})}
size="extraLarge"
onClick={() => setQuestionIdx(questions.length)}
>
Skip
</Button>
</>
}
isMacosDesktop={isMacosDesktop}
isWindowsDesktop={isWindowsDesktop}
>
<div className={styles.content}>
<h1 className={styles.question}>{question.question}</h1>
<div className={styles.optionsWrapper}>
{question.options &&
question.options.length > 0 &&
question.options.map((option, optionIndex) => {
if (option.type === 'checkbox') {
return (
<Checkbox
key={optionIndex}
name={option.value}
className={styles.checkBox}
labelClassName={styles.label}
checked={options.has(option.value)}
onChange={e => {
setOptions(set => {
if (e.target.checked) {
set.add(option.value);
} else {
set.delete(option.value);
}
return new Set(set);
});
}}
label={option.label}
/>
);
} else if (option.type === 'input') {
return (
<Input
key={optionIndex}
className={styles.input}
type="text"
size="large"
placeholder={option.label}
value={inputs[option.value] || ''}
onChange={value =>
setInputs(prev => ({ ...prev, [option.value]: value }))
}
/>
);
}
return null;
})}
</div>
<div className={styles.buttonWrapper}>
<Button
className={clsx(styles.button, {
[styles.disableButton]: questionIdx !== 0,
})}
size="extraLarge"
onClick={() => setQuestionIdx(questions.length)}
>
Skip
</Button>
<Button
className={styles.button}
type="primary"
size="extraLarge"
itemType="submit"
onClick={() => {
if (question.id && user?.id) {
const answer: QuestionnaireAnswer = {
form: user.id,
ask: question.id,
answer: [
...Array.from(options),
...Object.entries(inputs).map(
([key, value]) => `${key}:${value}`
),
],
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
fetchWithTraceReport('/api/worker/questionnaire', {
method: 'POST',
body: JSON.stringify(answer),
}).finally(() => {
setOptions(new Set());
setInputs({});
setQuestionIdx(questionIdx + 1);
});
} else {
setQuestionIdx(questionIdx + 1);
}
}}
iconPosition="end"
icon={<ArrowRightSmallIcon />}
>
{questionIdx === 0 ? 'start' : 'Next'}
</Button>
</div>
</div>
</ScrollableLayout>
);
}
return (
<ScrollableLayout
headerItems={isWindowsDesktop ? windowControl : null}
isMacosDesktop={isMacosDesktop}
isWindowsDesktop={isWindowsDesktop}
>
<div className={styles.thankContainer}>
<h1 className={styles.thankTitle}>Thank you!</h1>
<p className={styles.thankText}>
We will continue to enhance our products based on your feedback. Thank
you once again for your supports.
</p>
<Button
className={clsx(styles.button, styles.openAFFiNEButton)}
type="primary"
size="extraLarge"
onClick={() => {
if (callbackUrl) {
navigate(callbackUrl);
} else {
onOpenAffine();
}
}}
iconPosition="end"
icon={<ArrowRightSmallIcon />}
>
Get Started
</Button>
</div>
</ScrollableLayout>
);
};