feat(client): refactor auth/login
Some checks failed
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build failed

Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
2026-01-02 16:48:16 +08:00
parent 0a4f459188
commit af43b86a61
10 changed files with 134 additions and 59 deletions

View File

@@ -1,4 +1,5 @@
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import type { AuthorizeSearchParams } from '@/routes/authorize';
import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate } from '@tanstack/react-router';
import { useRef, useState } from 'react';
@@ -15,9 +16,12 @@ import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
import { cn } from '@/lib/utils';
export function LoginForm({
oauthParams,
className,
...props
}: React.ComponentProps<'div'>) {
}: React.ComponentProps<'div'> & {
oauthParams: AuthorizeSearchParams;
}) {
const formRef = useRef<HTMLFormElement>(null);
const turnstileRef = useRef<TurnstileInstance>(null);
const [token, setToken] = useState<string | null>(null);
@@ -28,7 +32,7 @@ export function LoginForm({
event.preventDefault();
const formData = new FormData(formRef.current!);
const email = formData.get('email')! as string;
mutateAsync({ email, turnstile_token: token! }).then(() => {
mutateAsync({ email, turnstile_token: token!, ...oauthParams }).then(() => {
void navigate({ to: '/magicLinkSent', search: { email } });
}).catch((error) => {
console.error(error);

View File

@@ -1,7 +1,8 @@
import type { AuthorizeSearchParams } from '@/routes/authorize';
import { useMutation } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
interface GetMagicLinkPayload {
interface GetMagicLinkPayload extends AuthorizeSearchParams {
email: string;
turnstile_token: string;
}
@@ -9,7 +10,7 @@ interface GetMagicLinkPayload {
export function useGetMagicLink() {
return useMutation({
mutationFn: async (payload: GetMagicLinkPayload) => {
return axiosClient.post<object>('/auth/magic', payload);
return axiosClient.post<{ status: string }>('/auth/magic', payload);
},
});
}

View File

@@ -7,7 +7,7 @@ export function useLogout() {
const logout = useCallback(() => {
clearTokens();
void navigate({ to: '/login' });
void navigate({ to: '/authorize' });
}, [navigate]);
return { logout };

14
client/src/lib/random.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Generate a cryptographically secure OAuth2 state string
* base64url encoded, URL-safe
*/
export function generateOAuthState(bytes: number = 32): string {
const random = new Uint8Array(bytes);
crypto.getRandomValues(random);
// base64url encode
return btoa(String.fromCharCode(...random))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

View File

@@ -29,6 +29,12 @@ export function clearTokens() {
setRefreshToken('');
}
export async function doSetTokenByCode(code: string) {
const { data } = await axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/token', { code });
setToken(data.access_token);
setRefreshToken(data.refresh_token);
}
export async function doRefreshToken() {
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
}

View File

@@ -9,20 +9,26 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as TokenRouteImport } from './routes/token'
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthorizeRouteImport } from './routes/authorize'
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
const TokenRoute = TokenRouteImport.update({
id: '/token',
path: '/token',
getParentRoute: () => rootRouteImport,
} as any)
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
id: '/magicLinkSent',
path: '/magicLinkSent',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
const AuthorizeRoute = AuthorizeRouteImport.update({
id: '/authorize',
path: '/authorize',
getParentRoute: () => rootRouteImport,
} as any)
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
@@ -41,47 +47,59 @@ const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
} as any)
export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/': typeof SidebarLayoutIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/': typeof SidebarLayoutIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
'/login': typeof LoginRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/login' | '/magicLinkSent' | '/profile' | '/'
fullPaths: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/magicLinkSent' | '/profile' | '/'
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
id:
| '__root__'
| '/_sidebarLayout'
| '/login'
| '/authorize'
| '/magicLinkSent'
| '/token'
| '/_sidebarLayout/profile'
| '/_sidebarLayout/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
LoginRoute: typeof LoginRoute
AuthorizeRoute: typeof AuthorizeRoute
MagicLinkSentRoute: typeof MagicLinkSentRoute
TokenRoute: typeof TokenRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/token': {
id: '/token'
path: '/token'
fullPath: '/token'
preLoaderRoute: typeof TokenRouteImport
parentRoute: typeof rootRouteImport
}
'/magicLinkSent': {
id: '/magicLinkSent'
path: '/magicLinkSent'
@@ -89,11 +107,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MagicLinkSentRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
'/authorize': {
id: '/authorize'
path: '/authorize'
fullPath: '/authorize'
preLoaderRoute: typeof AuthorizeRouteImport
parentRoute: typeof rootRouteImport
}
'/_sidebarLayout': {
@@ -136,8 +154,9 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
LoginRoute: LoginRoute,
AuthorizeRoute: AuthorizeRoute,
MagicLinkSentRoute: MagicLinkSentRoute,
TokenRoute: TokenRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -7,7 +7,7 @@ export const Route = createFileRoute('/_sidebarLayout/')({
loader: async () => {
if (!hasToken()) {
throw redirect({
to: '/login',
to: '/authorize',
});
}
},

View File

@@ -0,0 +1,42 @@
import { createFileRoute } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { LoginForm } from '@/components/login-form';
import { generateOAuthState } from '@/lib/random';
import { getToken } from '@/lib/token';
const authorizeSchema = z.object({
response_type: z.literal('code').default('code'),
client_id: z.literal('org_client').default('org_client'),
redirect_uri: z.string().default(`${new URL(import.meta.env.VITE_APP_BASE_URL as string).toString()}token`),
state: z.string().default(generateOAuthState()),
});
export type AuthorizeSearchParams = z.infer<typeof authorizeSchema>;
export const Route = createFileRoute('/authorize')({
component: RouteComponent,
validateSearch: zodValidator(authorizeSchema),
});
function RouteComponent() {
const token = getToken();
const oauthParams = Route.useSearch();
if (token !== null) {
const base = new URL(window.location.origin);
const url = new URL('/api/v1/auth/redirect', base);
url.searchParams.set('client_id', oauthParams.client_id);
url.searchParams.set('response_type', oauthParams.response_type);
url.searchParams.set('redirect_uri', oauthParams.redirect_uri);
url.searchParams.set('state', oauthParams.state);
window.location.href = url.toString();
return null;
}
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm oauthParams={oauthParams} />
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { createFileRoute, Navigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { LoginForm } from '@/components/login-form';
import { useValidateMagicLink } from '@/hooks/data/useValidateMagicLink';
import { setRefreshToken, setToken } from '@/lib/token';
const loginMagicLinkReceiverSchema = z.object({
ticket: z.string().optional(),
});
export const Route = createFileRoute('/login')({
component: RouteComponent,
validateSearch: zodValidator(loginMagicLinkReceiverSchema),
});
function ReceiveMagicLinkComponent() {
const { ticket } = Route.useSearch();
const { data } = useValidateMagicLink(ticket!);
setToken(data.data.access_token);
setRefreshToken(data.data.refresh_token);
return <Navigate to="/" />;
}
function RouteComponent() {
const { ticket } = Route.useSearch();
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
{ticket === undefined ? <LoginForm /> : <ReceiveMagicLinkComponent />}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import z from 'zod';
import { doSetTokenByCode } from '@/lib/token';
const tokenCodeSchema = z.object({
code: z.string().nonempty(),
});
export const Route = createFileRoute('/token')({
component: RouteComponent,
validateSearch: tokenCodeSchema,
});
function RouteComponent() {
const { code } = Route.useSearch();
const [status, setStatus] = useState('Loading...');
const navigate = useNavigate();
doSetTokenByCode(code).then(() => {
void navigate({ to: '/' });
}).catch((_) => {
setStatus('Error getting token');
});
return <div>{status}</div>;
}