feat(client): refactor auth/login
Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
|
import type { AuthorizeSearchParams } from '@/routes/authorize';
|
||||||
import { Turnstile } from '@marsidev/react-turnstile';
|
import { Turnstile } from '@marsidev/react-turnstile';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
@@ -15,9 +16,12 @@ import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
|
oauthParams,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'>) {
|
}: React.ComponentProps<'div'> & {
|
||||||
|
oauthParams: AuthorizeSearchParams;
|
||||||
|
}) {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
@@ -28,7 +32,7 @@ export function LoginForm({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(formRef.current!);
|
const formData = new FormData(formRef.current!);
|
||||||
const email = formData.get('email')! as string;
|
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 } });
|
void navigate({ to: '/magicLinkSent', search: { email } });
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import type { AuthorizeSearchParams } from '@/routes/authorize';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { axiosClient } from '@/lib/axios';
|
import { axiosClient } from '@/lib/axios';
|
||||||
|
|
||||||
interface GetMagicLinkPayload {
|
interface GetMagicLinkPayload extends AuthorizeSearchParams {
|
||||||
email: string;
|
email: string;
|
||||||
turnstile_token: string;
|
turnstile_token: string;
|
||||||
}
|
}
|
||||||
@@ -9,7 +10,7 @@ interface GetMagicLinkPayload {
|
|||||||
export function useGetMagicLink() {
|
export function useGetMagicLink() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: GetMagicLinkPayload) => {
|
mutationFn: async (payload: GetMagicLinkPayload) => {
|
||||||
return axiosClient.post<object>('/auth/magic', payload);
|
return axiosClient.post<{ status: string }>('/auth/magic', payload);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function useLogout() {
|
|||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
void navigate({ to: '/login' });
|
void navigate({ to: '/authorize' });
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
return { logout };
|
return { logout };
|
||||||
|
|||||||
14
client/src/lib/random.ts
Normal file
14
client/src/lib/random.ts
Normal 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(/=+$/, '');
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ export function clearTokens() {
|
|||||||
setRefreshToken('');
|
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() {
|
export async function doRefreshToken() {
|
||||||
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
|
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as TokenRouteImport } from './routes/token'
|
||||||
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
|
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 SidebarLayoutRouteImport } from './routes/_sidebarLayout'
|
||||||
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
|
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
|
||||||
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
|
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
|
||||||
|
|
||||||
|
const TokenRoute = TokenRouteImport.update({
|
||||||
|
id: '/token',
|
||||||
|
path: '/token',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
|
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
|
||||||
id: '/magicLinkSent',
|
id: '/magicLinkSent',
|
||||||
path: '/magicLinkSent',
|
path: '/magicLinkSent',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const AuthorizeRoute = AuthorizeRouteImport.update({
|
||||||
id: '/login',
|
id: '/authorize',
|
||||||
path: '/login',
|
path: '/authorize',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
|
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
|
||||||
@@ -41,47 +47,59 @@ const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
|
|||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/login': typeof LoginRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
'/profile': typeof SidebarLayoutProfileRoute
|
'/profile': typeof SidebarLayoutProfileRoute
|
||||||
'/': typeof SidebarLayoutIndexRoute
|
'/': typeof SidebarLayoutIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
'/profile': typeof SidebarLayoutProfileRoute
|
'/profile': typeof SidebarLayoutProfileRoute
|
||||||
'/': typeof SidebarLayoutIndexRoute
|
'/': typeof SidebarLayoutIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
|
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
|
||||||
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/login' | '/magicLinkSent' | '/profile' | '/'
|
fullPaths: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/login' | '/magicLinkSent' | '/profile' | '/'
|
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_sidebarLayout'
|
| '/_sidebarLayout'
|
||||||
| '/login'
|
| '/authorize'
|
||||||
| '/magicLinkSent'
|
| '/magicLinkSent'
|
||||||
|
| '/token'
|
||||||
| '/_sidebarLayout/profile'
|
| '/_sidebarLayout/profile'
|
||||||
| '/_sidebarLayout/'
|
| '/_sidebarLayout/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
|
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
|
||||||
LoginRoute: typeof LoginRoute
|
AuthorizeRoute: typeof AuthorizeRoute
|
||||||
MagicLinkSentRoute: typeof MagicLinkSentRoute
|
MagicLinkSentRoute: typeof MagicLinkSentRoute
|
||||||
|
TokenRoute: typeof TokenRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/token': {
|
||||||
|
id: '/token'
|
||||||
|
path: '/token'
|
||||||
|
fullPath: '/token'
|
||||||
|
preLoaderRoute: typeof TokenRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/magicLinkSent': {
|
'/magicLinkSent': {
|
||||||
id: '/magicLinkSent'
|
id: '/magicLinkSent'
|
||||||
path: '/magicLinkSent'
|
path: '/magicLinkSent'
|
||||||
@@ -89,11 +107,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof MagicLinkSentRouteImport
|
preLoaderRoute: typeof MagicLinkSentRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
'/authorize': {
|
||||||
id: '/login'
|
id: '/authorize'
|
||||||
path: '/login'
|
path: '/authorize'
|
||||||
fullPath: '/login'
|
fullPath: '/authorize'
|
||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof AuthorizeRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_sidebarLayout': {
|
'/_sidebarLayout': {
|
||||||
@@ -136,8 +154,9 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
|
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
|
||||||
LoginRoute: LoginRoute,
|
AuthorizeRoute: AuthorizeRoute,
|
||||||
MagicLinkSentRoute: MagicLinkSentRoute,
|
MagicLinkSentRoute: MagicLinkSentRoute,
|
||||||
|
TokenRoute: TokenRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const Route = createFileRoute('/_sidebarLayout/')({
|
|||||||
loader: async () => {
|
loader: async () => {
|
||||||
if (!hasToken()) {
|
if (!hasToken()) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: '/login',
|
to: '/authorize',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
42
client/src/routes/authorize.tsx
Normal file
42
client/src/routes/authorize.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
25
client/src/routes/token.tsx
Normal file
25
client/src/routes/token.tsx
Normal 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>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user