feat: add support for legacy traditional login methods
This commit is contained in:
parent
06d6ecd2a3
commit
3afac062c4
@ -350,6 +350,9 @@ importers:
|
|||||||
specifier: ^4.4.1
|
specifier: ^4.4.1
|
||||||
version: 4.4.1(@types/react@18.2.78)(immer@9.0.21)(react@18.2.0)
|
version: 4.4.1(@types/react@18.2.78)(immer@9.0.21)(react@18.2.0)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@auth/core':
|
||||||
|
specifier: ^0.34.1
|
||||||
|
version: 0.34.1(nodemailer@6.9.8)
|
||||||
'@i18next-toolkit/cli':
|
'@i18next-toolkit/cli':
|
||||||
specifier: ^1.2.2
|
specifier: ^1.2.2
|
||||||
version: 1.2.2(buffer@6.0.3)(typescript@5.5.4)
|
version: 1.2.2(buffer@6.0.3)(typescript@5.5.4)
|
||||||
@ -422,9 +425,6 @@ importers:
|
|||||||
'@auth/express':
|
'@auth/express':
|
||||||
specifier: ^0.5.5
|
specifier: ^0.5.5
|
||||||
version: 0.5.6(express@4.18.2)(nodemailer@6.9.8)
|
version: 0.5.6(express@4.18.2)(nodemailer@6.9.8)
|
||||||
'@auth/prisma-adapter':
|
|
||||||
specifier: ^2.1.0
|
|
||||||
version: 2.4.1(@prisma/client@5.14.0(prisma@5.14.0))(nodemailer@6.9.8)
|
|
||||||
'@paralleldrive/cuid2':
|
'@paralleldrive/cuid2':
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
@ -1173,11 +1173,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
express: ^4.18.2
|
express: ^4.18.2
|
||||||
|
|
||||||
'@auth/prisma-adapter@2.4.1':
|
|
||||||
resolution: {integrity: sha512-VF5IOTHEWHX6WHUxIbsbc12m34cp5T82fzJfi7DmzwBZb89UsoROejV8l0B1dCTZ+pDIS0d4zN9dSa2gIKywNQ==}
|
|
||||||
peerDependencies:
|
|
||||||
'@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5'
|
|
||||||
|
|
||||||
'@babel/code-frame@7.24.7':
|
'@babel/code-frame@7.24.7':
|
||||||
resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==}
|
resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@ -13990,15 +13985,6 @@ snapshots:
|
|||||||
- '@simplewebauthn/server'
|
- '@simplewebauthn/server'
|
||||||
- nodemailer
|
- nodemailer
|
||||||
|
|
||||||
'@auth/prisma-adapter@2.4.1(@prisma/client@5.14.0(prisma@5.14.0))(nodemailer@6.9.8)':
|
|
||||||
dependencies:
|
|
||||||
'@auth/core': 0.34.1(nodemailer@6.9.8)
|
|
||||||
'@prisma/client': 5.14.0(prisma@5.14.0)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@simplewebauthn/browser'
|
|
||||||
- '@simplewebauthn/server'
|
|
||||||
- nodemailer
|
|
||||||
|
|
||||||
'@babel/code-frame@7.24.7':
|
'@babel/code-frame@7.24.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/highlight': 7.24.7
|
'@babel/highlight': 7.24.7
|
||||||
|
@ -43,7 +43,7 @@ const AppRouter: React.FC = React.memo(() => {
|
|||||||
<RouterProvider router={router} context={{ userInfo }} />
|
<RouterProvider router={router} context={{ userInfo }} />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster position="top-center" />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,15 +1,44 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
const TOKEN_STORAGE_KEY = 'jsonwebtoken';
|
const TOKEN_STORAGE_KEY = 'jsonwebtoken';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
export function getJWT(): string | null {
|
export function getJWT(): string | null {
|
||||||
const token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
|
const token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||||
|
|
||||||
return token ?? null;
|
return token ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
export function setJWT(jwt: string) {
|
export function setJWT(jwt: string) {
|
||||||
window.localStorage.setItem(TOKEN_STORAGE_KEY, jwt);
|
window.localStorage.setItem(TOKEN_STORAGE_KEY, jwt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
export function clearJWT() {
|
export function clearJWT() {
|
||||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSession(): Promise<{
|
||||||
|
user: {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
expires: string;
|
||||||
|
} | null> {
|
||||||
|
const { data } = await axios.get('/api/auth/session');
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
185
src/client/api/authjs/lib.tsx
Normal file
185
src/client/api/authjs/lib.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* This file is fork from next-auth/react
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ClientSafeProvider,
|
||||||
|
LiteralUnion,
|
||||||
|
SignInAuthorizationParams,
|
||||||
|
SignInOptions,
|
||||||
|
SignInResponse,
|
||||||
|
SignOutParams,
|
||||||
|
SignOutResponse,
|
||||||
|
} from './types';
|
||||||
|
import type {
|
||||||
|
BuiltInProviderType,
|
||||||
|
RedirectableProviderType,
|
||||||
|
} from '@auth/core/providers';
|
||||||
|
import { Session } from '@auth/core/types';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
type UpdateSession = (data?: any) => Promise<Session | null>;
|
||||||
|
|
||||||
|
export type SessionContextValue<R extends boolean = false> = R extends true
|
||||||
|
?
|
||||||
|
| { update: UpdateSession; data: Session; status: 'authenticated' }
|
||||||
|
| { update: UpdateSession; data: null; status: 'loading' }
|
||||||
|
:
|
||||||
|
| { update: UpdateSession; data: Session; status: 'authenticated' }
|
||||||
|
| {
|
||||||
|
update: UpdateSession;
|
||||||
|
data: null;
|
||||||
|
status: 'unauthenticated' | 'loading';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current Cross Site Request Forgery Token (CSRF Token)
|
||||||
|
* required to make POST requests (e.g. for signing in and signing out).
|
||||||
|
* You likely only need to use this if you are not using the built-in
|
||||||
|
* `signIn()` and `signOut()` methods.
|
||||||
|
*
|
||||||
|
* [Documentation](https://next-auth.js.org/getting-started/client#getcsrftoken)
|
||||||
|
*/
|
||||||
|
export async function getCsrfToken() {
|
||||||
|
const { data } = await axios.get<{ csrfToken: string }>('/api/auth/csrf');
|
||||||
|
|
||||||
|
return data.csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It calls `/api/auth/providers` and returns
|
||||||
|
* a list of the currently configured authentication providers.
|
||||||
|
* It can be useful if you are creating a dynamic custom sign in page.
|
||||||
|
*
|
||||||
|
* [Documentation](https://next-auth.js.org/getting-started/client#getproviders)
|
||||||
|
*/
|
||||||
|
export async function getProviders() {
|
||||||
|
const { data } = await axios.get<
|
||||||
|
Record<LiteralUnion<BuiltInProviderType>, ClientSafeProvider>
|
||||||
|
>('/api/auth/providers');
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side method to initiate a signin flow
|
||||||
|
* or send the user to the signin page listing all possible providers.
|
||||||
|
* Automatically adds the CSRF token to the request.
|
||||||
|
*
|
||||||
|
* [Documentation](https://next-auth.js.org/getting-started/client#signin)
|
||||||
|
*/
|
||||||
|
export async function signIn<
|
||||||
|
P extends RedirectableProviderType | undefined = undefined,
|
||||||
|
>(
|
||||||
|
provider?: LiteralUnion<
|
||||||
|
P extends RedirectableProviderType
|
||||||
|
? P | BuiltInProviderType
|
||||||
|
: BuiltInProviderType
|
||||||
|
>,
|
||||||
|
options?: SignInOptions,
|
||||||
|
authorizationParams?: SignInAuthorizationParams
|
||||||
|
): Promise<
|
||||||
|
P extends RedirectableProviderType ? SignInResponse | undefined : undefined
|
||||||
|
> {
|
||||||
|
const { callbackUrl = window.location.href, redirect = true } = options ?? {};
|
||||||
|
|
||||||
|
const baseUrl = '/api/auth';
|
||||||
|
const providers = await getProviders();
|
||||||
|
|
||||||
|
if (!providers) {
|
||||||
|
window.location.href = `${baseUrl}/error`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider || !(provider in providers)) {
|
||||||
|
window.location.href = `${baseUrl}/signin?${new URLSearchParams({
|
||||||
|
callbackUrl,
|
||||||
|
})}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCredentials = providers[provider].type === 'credentials';
|
||||||
|
const isEmail = providers[provider].type === 'email';
|
||||||
|
const isSupportingReturn = isCredentials || isEmail;
|
||||||
|
|
||||||
|
const signInUrl = `${baseUrl}/${
|
||||||
|
isCredentials ? 'callback' : 'signin'
|
||||||
|
}/${provider}`;
|
||||||
|
|
||||||
|
const _signInUrl = `${signInUrl}${authorizationParams ? `?${new URLSearchParams(authorizationParams)}` : ''}`;
|
||||||
|
|
||||||
|
const res = await fetch(_signInUrl, {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-Auth-Return-Redirect': '1',
|
||||||
|
},
|
||||||
|
// @ts-expect-error
|
||||||
|
body: new URLSearchParams({
|
||||||
|
...options,
|
||||||
|
csrfToken: await getCsrfToken(),
|
||||||
|
callbackUrl,
|
||||||
|
json: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// TODO: Do not redirect for Credentials and Email providers by default in next major
|
||||||
|
if (redirect || !isSupportingReturn) {
|
||||||
|
const url = data.url ?? callbackUrl;
|
||||||
|
window.location.href = url;
|
||||||
|
// If url contains a hash, the browser does not reload the page. We reload manually
|
||||||
|
if (url.includes('#')) window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new URL(data.url).searchParams.get('error');
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
status: res.status,
|
||||||
|
ok: res.ok,
|
||||||
|
url: error ? null : data.url,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the user out, by removing the session cookie.
|
||||||
|
* Automatically adds the CSRF token to the request.
|
||||||
|
*
|
||||||
|
* [Documentation](https://next-auth.js.org/getting-started/client#signout)
|
||||||
|
*/
|
||||||
|
export async function signOut<R extends boolean = true>(
|
||||||
|
options?: SignOutParams<R>
|
||||||
|
): Promise<R extends true ? undefined : SignOutResponse> {
|
||||||
|
const { callbackUrl = window.location.href } = options ?? {};
|
||||||
|
const baseUrl = '/api/auth';
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
// @ts-expect-error
|
||||||
|
body: new URLSearchParams({
|
||||||
|
csrfToken: await getCsrfToken(),
|
||||||
|
callbackUrl,
|
||||||
|
json: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const res = await fetch(`${baseUrl}/signout`, fetchOptions);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (options?.redirect ?? true) {
|
||||||
|
const url = data.url ?? callbackUrl;
|
||||||
|
window.location.href = url;
|
||||||
|
// If url contains a hash, the browser does not reload the page. We reload manually
|
||||||
|
if (url.includes('#')) window.location.reload();
|
||||||
|
// @ts-expect-error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
55
src/client/api/authjs/types.ts
Normal file
55
src/client/api/authjs/types.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import type { Session } from '@auth/core/types';
|
||||||
|
import type { BuiltInProviderType, ProviderType } from '@auth/core/providers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Util type that matches some strings literally, but allows any other string as well.
|
||||||
|
* @source https://github.com/microsoft/TypeScript/issues/29729#issuecomment-832522611
|
||||||
|
*/
|
||||||
|
export type LiteralUnion<T extends U, U = string> =
|
||||||
|
| T
|
||||||
|
| (U & Record<never, never>);
|
||||||
|
|
||||||
|
export interface ClientSafeProvider {
|
||||||
|
id: LiteralUnion<BuiltInProviderType>;
|
||||||
|
name: string;
|
||||||
|
type: ProviderType;
|
||||||
|
signinUrl: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignInOptions extends Record<string, unknown> {
|
||||||
|
/**
|
||||||
|
* Specify to which URL the user will be redirected after signing in. Defaults to the page URL the sign-in is initiated from.
|
||||||
|
*
|
||||||
|
* [Documentation](https://next-auth.js.org/getting-started/client#specifying-a-callbackurl)
|
||||||
|
*/
|
||||||
|
callbackUrl?: string;
|
||||||
|
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option) */
|
||||||
|
redirect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignInResponse {
|
||||||
|
error: string | null;
|
||||||
|
status: number;
|
||||||
|
ok: boolean;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match `inputType` of `new URLSearchParams(inputType)` */
|
||||||
|
export type SignInAuthorizationParams =
|
||||||
|
| string
|
||||||
|
| string[][]
|
||||||
|
| Record<string, string>
|
||||||
|
| URLSearchParams;
|
||||||
|
|
||||||
|
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option-1) */
|
||||||
|
export interface SignOutResponse {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignOutParams<R extends boolean = true> {
|
||||||
|
/** [Documentation](https://next-auth.js.org/getting-started/client#specifying-a-callbackurl-1) */
|
||||||
|
callbackUrl?: string;
|
||||||
|
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option-1 */
|
||||||
|
redirect?: R;
|
||||||
|
}
|
20
src/client/api/authjs/useAuth.ts
Normal file
20
src/client/api/authjs/useAuth.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useEvent } from '@/hooks/useEvent';
|
||||||
|
import { signIn } from './lib';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const loginWithPassword = useEvent(
|
||||||
|
async (username: string, password: string) => {
|
||||||
|
const res = await signIn('account', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loginWithPassword,
|
||||||
|
};
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useUserStore } from '../../store/user';
|
import { useUserStore } from '../../store/user';
|
||||||
import { useEvent } from '../../hooks/useEvent';
|
import { useEvent } from '../../hooks/useEvent';
|
||||||
import { clearJWT } from '../auth';
|
import { clearJWT } from '../authjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock
|
* Mock
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { get } from 'lodash-es';
|
import { get } from 'lodash-es';
|
||||||
import { getJWT } from './auth';
|
import { getJWT } from './authjs';
|
||||||
|
|
||||||
class RequestError extends Error {}
|
class RequestError extends Error {}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
import { getJWT } from './auth';
|
import { getJWT } from './authjs';
|
||||||
import type { SubscribeEventMap, SocketEventMap } from '../../server/ws/shared';
|
import type { SubscribeEventMap, SocketEventMap } from '../../server/ws/shared';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { useEvent } from '../hooks/useEvent';
|
import { useEvent } from '../hooks/useEvent';
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
splitLink,
|
splitLink,
|
||||||
TRPCClientErrorLike,
|
TRPCClientErrorLike,
|
||||||
} from '@trpc/client';
|
} from '@trpc/client';
|
||||||
import { getJWT } from './auth';
|
import { getJWT } from './authjs';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import { isDev } from '../utils/env';
|
import { isDev } from '../utils/env';
|
||||||
|
|
||||||
@ -22,9 +22,14 @@ export type AppRouterOutput = inferRouterOutputs<AppRouter>;
|
|||||||
const url = '/trpc';
|
const url = '/trpc';
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
|
const jwt = getJWT();
|
||||||
|
if (jwt) {
|
||||||
return {
|
return {
|
||||||
Authorization: `Bearer ${getJWT()}`,
|
Authorization: `Bearer ${getJWT()}`,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const trpcClient = trpc.createClient({
|
export const trpcClient = trpc.createClient({
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { getJWT, setJWT } from '../api/auth';
|
|
||||||
import { Loading } from './Loading';
|
import { Loading } from './Loading';
|
||||||
import { trpc } from '../api/trpc';
|
import { trpc } from '../api/trpc';
|
||||||
import { setUserInfo } from '../store/user';
|
import { setUserInfo } from '../store/user';
|
||||||
@ -7,26 +6,19 @@ import { setUserInfo } from '../store/user';
|
|||||||
export const TokenLoginContainer: React.FC<React.PropsWithChildren> =
|
export const TokenLoginContainer: React.FC<React.PropsWithChildren> =
|
||||||
React.memo((props) => {
|
React.memo((props) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const mutation = trpc.user.loginWithToken.useMutation();
|
const trpcUtils = trpc.useUtils();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getJWT();
|
trpcUtils.user.info
|
||||||
if (token) {
|
.fetch()
|
||||||
mutation
|
.then((userInfo) => {
|
||||||
.mutateAsync({
|
if (userInfo) {
|
||||||
token,
|
setUserInfo(userInfo);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then((res) => {
|
|
||||||
setJWT(res.token);
|
|
||||||
setUserInfo(res.info);
|
|
||||||
})
|
|
||||||
.catch((err) => {})
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
22
src/client/hooks/useOnline.ts
Normal file
22
src/client/hooks/useOnline.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useOnline() {
|
||||||
|
const [isOnline, setIsOnline] = useState(
|
||||||
|
typeof navigator !== 'undefined' ? navigator.onLine : false
|
||||||
|
);
|
||||||
|
|
||||||
|
const setOnline = () => setIsOnline(true);
|
||||||
|
const setOffline = () => setIsOnline(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('online', setOnline);
|
||||||
|
window.addEventListener('offline', setOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', setOnline);
|
||||||
|
window.removeEventListener('offline', setOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isOnline;
|
||||||
|
}
|
@ -98,6 +98,7 @@
|
|||||||
"zustand": "^4.4.1"
|
"zustand": "^4.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@auth/core": "^0.34.1",
|
||||||
"@i18next-toolkit/cli": "^1.2.2",
|
"@i18next-toolkit/cli": "^1.2.2",
|
||||||
"@tanstack/router-vite-plugin": "^1.20.5",
|
"@tanstack/router-vite-plugin": "^1.20.5",
|
||||||
"@types/jsonexport": "^3.0.5",
|
"@types/jsonexport": "^3.0.5",
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
|
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
|
||||||
import { useRequest } from '@/hooks/useRequest';
|
|
||||||
import { setJWT } from '@/api/auth';
|
|
||||||
import { useGlobalConfig } from '@/hooks/useConfig';
|
import { useGlobalConfig } from '@/hooks/useConfig';
|
||||||
import { trpc } from '@/api/trpc';
|
import { trpc } from '@/api/trpc';
|
||||||
import { Form, Typography } from 'antd';
|
import { Form, Typography } from 'antd';
|
||||||
@ -9,6 +7,9 @@ import { setUserInfo } from '@/store/user';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useAuth } from '@/api/authjs/useAuth';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useEventWithLoading } from '@/hooks/useEvent';
|
||||||
|
|
||||||
export const Route = createFileRoute('/login')({
|
export const Route = createFileRoute('/login')({
|
||||||
validateSearch: z.object({
|
validateSearch: z.object({
|
||||||
@ -28,17 +29,27 @@ export const Route = createFileRoute('/login')({
|
|||||||
function LoginComponent() {
|
function LoginComponent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const loginMutation = trpc.user.login.useMutation();
|
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
|
const trpcUtils = trpc.useUtils();
|
||||||
|
|
||||||
const [{ loading }, handleLogin] = useRequest(async (values: any) => {
|
const { loginWithPassword } = useAuth();
|
||||||
const res = await loginMutation.mutateAsync({
|
|
||||||
username: values.username,
|
const [handleLogin, loading] = useEventWithLoading(async (values: any) => {
|
||||||
password: values.password,
|
const res = await loginWithPassword(values.username, values.password);
|
||||||
});
|
|
||||||
|
if (res?.error) {
|
||||||
|
toast.error(t('Login failed, please check your username and password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = await trpcUtils.user.info.fetch();
|
||||||
|
if (!userInfo) {
|
||||||
|
toast.error(t('Can not get current user info'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserInfo(userInfo);
|
||||||
|
|
||||||
setJWT(res.token);
|
|
||||||
setUserInfo(res.info);
|
|
||||||
navigate({
|
navigate({
|
||||||
to: search.redirect ?? '/',
|
to: search.redirect ?? '/',
|
||||||
replace: true,
|
replace: true,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Form, Typography } from 'antd';
|
import { Form, Typography } from 'antd';
|
||||||
import { useRequest } from '../hooks/useRequest';
|
import { useRequest } from '../hooks/useRequest';
|
||||||
import { trpc } from '../api/trpc';
|
import { trpc } from '../api/trpc';
|
||||||
import { setJWT } from '../api/auth';
|
import { setJWT } from '../api/authjs';
|
||||||
import { setUserInfo } from '../store/user';
|
import { setUserInfo } from '../store/user';
|
||||||
import { useTranslation } from '@i18next-toolkit/react';
|
import { useTranslation } from '@i18next-toolkit/react';
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { createSocketIOClient } from '../api/socketio';
|
import { createSocketIOClient } from '../api/socketio';
|
||||||
import { AppRouterOutput } from '../api/trpc';
|
import { AppRouterOutput } from '../api/trpc';
|
||||||
|
|
||||||
type UserLoginInfo = AppRouterOutput['user']['loginWithToken']['info'];
|
type UserLoginInfo = NonNullable<AppRouterOutput['user']['info']>;
|
||||||
|
|
||||||
interface UserState {
|
interface UserState {
|
||||||
info: UserLoginInfo | null;
|
info: UserLoginInfo | null;
|
||||||
@ -17,7 +17,6 @@ export function setUserInfo(info: UserLoginInfo) {
|
|||||||
// Make sure currentWorkspace existed
|
// Make sure currentWorkspace existed
|
||||||
info.currentWorkspace = {
|
info.currentWorkspace = {
|
||||||
...info.workspaces[0].workspace,
|
...info.workspaces[0].workspace,
|
||||||
dashboardLayout: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,6 +33,9 @@ export default defineConfig({
|
|||||||
'/trpc': {
|
'/trpc': {
|
||||||
target: 'http://localhost:12345',
|
target: 'http://localhost:12345',
|
||||||
},
|
},
|
||||||
|
'/api/auth/': {
|
||||||
|
target: 'http://localhost:12345',
|
||||||
|
},
|
||||||
'/api/workspace': {
|
'/api/workspace': {
|
||||||
target: 'http://localhost:12345',
|
target: 'http://localhost:12345',
|
||||||
},
|
},
|
||||||
|
@ -28,11 +28,7 @@ export const userInfoSchema = z.object({
|
|||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
deletedAt: z.date().nullable(),
|
deletedAt: z.date().nullable(),
|
||||||
currentWorkspace: workspaceSchema.merge(
|
currentWorkspace: workspaceSchema,
|
||||||
z.object({
|
|
||||||
dashboardLayout: workspaceDashboardLayoutSchema.nullable(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
workspaces: z.array(
|
workspaces: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
role: z.string(),
|
role: z.string(),
|
||||||
|
@ -14,11 +14,14 @@ import { authUser, createUserWithAuthjs } from './user.js';
|
|||||||
import { createTransport } from 'nodemailer';
|
import { createTransport } from 'nodemailer';
|
||||||
import { Theme } from '@auth/core/types';
|
import { Theme } from '@auth/core/types';
|
||||||
import { generateSMTPHTML } from '../utils/smtp.js';
|
import { generateSMTPHTML } from '../utils/smtp.js';
|
||||||
|
import { SYSTEM_ROLES } from '@tianji/shared';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
export const authConfig: Omit<AuthConfig, 'raw'> = {
|
export const authConfig: Omit<AuthConfig, 'raw'> = {
|
||||||
debug: true,
|
debug: true,
|
||||||
providers: [
|
providers: [
|
||||||
Credentials({
|
Credentials({
|
||||||
|
id: 'account',
|
||||||
name: 'Account',
|
name: 'Account',
|
||||||
credentials: {
|
credentials: {
|
||||||
username: { label: 'Username' },
|
username: { label: 'Username' },
|
||||||
@ -41,6 +44,7 @@ export const authConfig: Omit<AuthConfig, 'raw'> = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Nodemailer({
|
Nodemailer({
|
||||||
|
id: 'email',
|
||||||
name: 'Email',
|
name: 'Email',
|
||||||
...env.auth.email,
|
...env.auth.email,
|
||||||
async sendVerificationRequest(params) {
|
async sendVerificationRequest(params) {
|
||||||
@ -63,6 +67,23 @@ export const authConfig: Omit<AuthConfig, 'raw'> = {
|
|||||||
],
|
],
|
||||||
adapter: TianjiPrismaAdapter(prisma),
|
adapter: TianjiPrismaAdapter(prisma),
|
||||||
secret: env.auth.secret,
|
secret: env.auth.secret,
|
||||||
|
session: {
|
||||||
|
strategy: 'jwt',
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
jwt({ token }) {
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
role: SYSTEM_ROLES.user,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
session({ session, token, user }) {
|
||||||
|
_.set(session, ['user', 'id'], token.sub);
|
||||||
|
_.set(session, ['user', 'role'], token.role);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function toAdapterUser(
|
function toAdapterUser(
|
||||||
@ -132,9 +153,16 @@ function TianjiPrismaAdapter(
|
|||||||
where: { sessionToken },
|
where: { sessionToken },
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
});
|
});
|
||||||
if (!userAndSession) return null;
|
if (!userAndSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const { user, ...session } = userAndSession;
|
const { user, ...session } = userAndSession;
|
||||||
return { user, session } as {
|
|
||||||
|
return {
|
||||||
|
user: toAdapterUser(user),
|
||||||
|
session,
|
||||||
|
} as {
|
||||||
user: AdapterUser;
|
user: AdapterUser;
|
||||||
session: AdapterSession;
|
session: AdapterSession;
|
||||||
};
|
};
|
||||||
|
@ -36,8 +36,6 @@ const createUserSelect = {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
dashboardOrder: true,
|
|
||||||
dashboardLayout: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
workspaces: {
|
workspaces: {
|
||||||
@ -184,6 +182,17 @@ export async function createUserWithAuthjs(data: Omit<AdapterUser, 'id'>) {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserInfo(userId: string) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
select: createUserSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
export async function authUser(username: string, password: string) {
|
export async function authUser(username: string, password: string) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
createAdminUser,
|
createAdminUser,
|
||||||
createUser,
|
createUser,
|
||||||
getUserCount,
|
getUserCount,
|
||||||
|
getUserInfo,
|
||||||
} from '../../model/user.js';
|
} from '../../model/user.js';
|
||||||
import { jwtSign } from '../../middleware/auth.js';
|
import { jwtSign } from '../../middleware/auth.js';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
@ -134,4 +135,10 @@ export const userRouter = router({
|
|||||||
|
|
||||||
return changeUserPassword(userId, oldPassword, newPassword);
|
return changeUserPassword(userId, oldPassword, newPassword);
|
||||||
}),
|
}),
|
||||||
|
info: protectProedure
|
||||||
|
.input(z.void())
|
||||||
|
.output(userInfoSchema.nullable())
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
return getUserInfo(ctx.user.id);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
@ -4,10 +4,12 @@ import { z } from 'zod';
|
|||||||
import { jwtVerify } from '../middleware/auth.js';
|
import { jwtVerify } from '../middleware/auth.js';
|
||||||
import { getWorkspaceUser } from '../model/workspace.js';
|
import { getWorkspaceUser } from '../model/workspace.js';
|
||||||
import { ROLES, SYSTEM_ROLES } from '@tianji/shared';
|
import { ROLES, SYSTEM_ROLES } from '@tianji/shared';
|
||||||
import type { IncomingMessage } from 'http';
|
import type { Request } from 'express';
|
||||||
import { OpenApiMeta } from 'trpc-openapi';
|
import { OpenApiMeta } from 'trpc-openapi';
|
||||||
|
import { getSession } from '@auth/express';
|
||||||
|
import { authConfig } from '../model/auth.js';
|
||||||
|
|
||||||
export function createContext({ req }: { req: IncomingMessage }) {
|
export async function createContext({ req }: { req: Request }) {
|
||||||
const authorization = req.headers['authorization'] ?? '';
|
const authorization = req.headers['authorization'] ?? '';
|
||||||
const token = authorization.replace('Bearer ', '');
|
const token = authorization.replace('Bearer ', '');
|
||||||
|
|
||||||
@ -24,12 +26,10 @@ export const router = t.router;
|
|||||||
export const publicProcedure = t.procedure;
|
export const publicProcedure = t.procedure;
|
||||||
|
|
||||||
const isUser = middleware(async (opts) => {
|
const isUser = middleware(async (opts) => {
|
||||||
|
// auth with token
|
||||||
const token = opts.ctx.token;
|
const token = opts.ctx.token;
|
||||||
|
|
||||||
if (!token) {
|
if (token) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'NoToken' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = jwtVerify(token);
|
const user = jwtVerify(token);
|
||||||
|
|
||||||
@ -41,6 +41,25 @@ const isUser = middleware(async (opts) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'TokenInvalid' });
|
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'TokenInvalid' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auth with session
|
||||||
|
const req = opts.ctx.req;
|
||||||
|
const session = await getSession(req, authConfig);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
return opts.next({
|
||||||
|
ctx: {
|
||||||
|
user: {
|
||||||
|
id: session.user?.id,
|
||||||
|
username: session.user?.name,
|
||||||
|
role: SYSTEM_ROLES.user,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No Token or Session' });
|
||||||
});
|
});
|
||||||
|
|
||||||
export const protectProedure = t.procedure.use(isUser);
|
export const protectProedure = t.procedure.use(isUser);
|
||||||
|
@ -8,7 +8,6 @@
|
|||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"baseUrl": ".",
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
|
18
src/server/types/@auth/core/index.d.ts
vendored
Normal file
18
src/server/types/@auth/core/index.d.ts
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { DefaultSession, User, DefaultJWT } from '@auth/express';
|
||||||
|
import type { SYSTEM_ROLES } from '@tianji/shared';
|
||||||
|
|
||||||
|
declare module '@auth/express' {
|
||||||
|
interface Session extends DefaultSession {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
image: string;
|
||||||
|
role: SYSTEM_ROLES;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JWT {
|
||||||
|
role: SYSTEM_ROLES;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user