mirror of
https://gitea.tendokyu.moe/sk1982/actaeon.git
synced 2026-03-21 17:54:19 -05:00
auth
This commit is contained in:
parent
c4fdb7be58
commit
ec582f2eef
119
src/actions/auth.ts
Normal file
119
src/actions/auth.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { auth, signIn, signOut } from '@/auth';
|
||||
import { AuthError } from 'next-auth';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { db } from '@/db';
|
||||
|
||||
export const getUser = async () => {
|
||||
const session = await auth();
|
||||
return session?.user;
|
||||
};
|
||||
|
||||
export const requireUser = async () => {
|
||||
return await getUser() ??
|
||||
redirect(`/auth/login?error=1&callbackUrl=${encodeURIComponent(new URL(headers().get('referer') ?? 'http://a/').pathname)}`);
|
||||
};
|
||||
|
||||
type LoginOptions = {
|
||||
username?: string | null,
|
||||
password?: string | null,
|
||||
redirectTo?: string | null
|
||||
}
|
||||
|
||||
export const login = async (options?: LoginOptions) => {
|
||||
if (!options)
|
||||
return signIn();
|
||||
|
||||
try {
|
||||
return await signIn('credentials', {
|
||||
...options,
|
||||
redirectTo: options?.redirectTo ?? '/'
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
if (e.type === 'CredentialsSignin')
|
||||
return { error: true, message: 'Invalid username or password' };
|
||||
|
||||
return { error: true, message: 'Unknown log in error' };
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async (options: { redirectTo?: string, redirect?: boolean }) => {
|
||||
return signOut(options);
|
||||
};
|
||||
|
||||
const emailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i;
|
||||
|
||||
export const register = async (formData: FormData) => {
|
||||
const username = formData.get('username')?.toString()?.trim();
|
||||
const password = formData.get('password')?.toString()?.trim();
|
||||
const email = formData.get('email')?.toString()?.trim();
|
||||
const password2 = formData.get('password2')?.toString()?.trim();
|
||||
const accessCode = formData.get('accessCode')?.toString()?.trim();
|
||||
|
||||
if (!username)
|
||||
return { error: true, message: 'Username required' };
|
||||
if (!password)
|
||||
return { error: true, message: 'Password required' };
|
||||
if (!email)
|
||||
return { error: true, message: 'Email required' };
|
||||
if (!accessCode)
|
||||
return { error: true, message: 'Access code required' };
|
||||
if (password !== password2)
|
||||
return { error: true, message: 'Passwords do not match' };
|
||||
if (password.length < 8)
|
||||
return { error: true, message: 'Password must be at least 8 characters' };
|
||||
if (!/^\d{20}$/.test(accessCode))
|
||||
return { error: true, message: 'Invalid access code format' };
|
||||
if (!emailRegex.test(email))
|
||||
return { error: true, message: 'Invalid email' };
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, process.env.BCRYPT_ROUNDS ? parseInt(process.env.BCRYPT_ROUNDS) : 12)
|
||||
|
||||
const existingUser = await db.selectFrom('aime_user as u')
|
||||
.leftJoin('aime_card as c', 'c.user', 'u.id')
|
||||
.where(({eb, and, or, fn}) =>
|
||||
or([
|
||||
eb(fn<string>('lower', ['u.username']), '=', username.toLowerCase()),
|
||||
eb(fn<string>('lower', ['u.email']), '=', email.toLowerCase()),
|
||||
and([
|
||||
eb('c.access_code', '=', accessCode),
|
||||
or([
|
||||
eb('u.username', 'is not', null),
|
||||
eb('u.email', 'is not', null)
|
||||
])
|
||||
])
|
||||
])
|
||||
)
|
||||
.select('u.id')
|
||||
.executeTakeFirst();
|
||||
|
||||
if (existingUser)
|
||||
return { error: true, message: 'User already exists' };
|
||||
|
||||
const user = await db.selectFrom('aime_user as u')
|
||||
.leftJoin('aime_card as c', 'c.user', 'u.id')
|
||||
.where(eb => eb('c.access_code', '=', accessCode)
|
||||
.and(eb('u.email', 'is', null))
|
||||
.and(eb('u.username', 'is', null)))
|
||||
.select('u.id')
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user)
|
||||
return { error: true, message: 'Access code does not exist' };
|
||||
|
||||
await db.updateTable('aime_user')
|
||||
.where('id', '=', user.id)
|
||||
.set('username', username)
|
||||
.set('password', hashedPassword)
|
||||
.set('email', email)
|
||||
.execute();
|
||||
|
||||
return { error: false };
|
||||
};
|
||||
10
src/app/(centered)/auth/login/page.tsx
Normal file
10
src/app/(centered)/auth/login/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { PageProps } from '@/types/page';
|
||||
import { headers } from 'next/headers';
|
||||
import { LoginCard } from '@/components/login-card';
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
const referer = headers().get('referer');
|
||||
const callback = searchParams?.['callbackUrl']?.toString();
|
||||
|
||||
return (<LoginCard initialError={searchParams?.['error']?.toString()} referer={referer} callback={callback} />);
|
||||
}
|
||||
6
src/app/(centered)/auth/register/page.tsx
Normal file
6
src/app/(centered)/auth/register/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { PageProps } from '@/types/page';
|
||||
import { RegisterCard } from '@/components/register-card';
|
||||
|
||||
export default async function RegisterPage({ searchParams }: PageProps) {
|
||||
return (<RegisterCard callback={searchParams?.['callbackUrl']?.toString()} />)
|
||||
}
|
||||
7
src/app/(centered)/layout.tsx
Normal file
7
src/app/(centered)/layout.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { LayoutProps } from '@/types/layout';
|
||||
|
||||
export default function CenteredLayout({ children }: LayoutProps) {
|
||||
return <div className="flex flex-col items-center justify-center h-full">
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
20
src/app/api/auth/[...nextauth]/route.ts
Normal file
20
src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { GET as authGet, POST as authPost } from '@/auth';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
let basePath = process.env.BASE_PATH ?? '';
|
||||
if (basePath.endsWith('/')) basePath = basePath.slice(0, -1);
|
||||
|
||||
// https://github.com/vercel/next.js/issues/62756
|
||||
const fixPath = (request: NextRequest) => {
|
||||
const newUrl = request.nextUrl.clone();
|
||||
// newUrl.pathname = `${basePath}${newUrl.pathname}`;
|
||||
newUrl.basePath = basePath;
|
||||
return new NextRequest(newUrl, request);
|
||||
};
|
||||
export async function GET(request: NextRequest) {
|
||||
return authGet(fixPath(request));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return authPost(fixPath(request));
|
||||
}
|
||||
68
src/auth.ts
Normal file
68
src/auth.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import NextAuth from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import { db } from '@/db';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DBUserPayload } from '@/types/user';
|
||||
|
||||
let basePath = process.env.BASE_PATH ?? '';
|
||||
if (basePath.endsWith('/')) basePath = basePath.slice(0, -1);
|
||||
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
signIn,
|
||||
signOut
|
||||
} = NextAuth({
|
||||
pages: {
|
||||
signIn: `${basePath}/auth/login`
|
||||
},
|
||||
basePath: `${basePath}/api/auth/`,
|
||||
session: {
|
||||
strategy: 'jwt'
|
||||
},
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
token.user ??= user;
|
||||
return token;
|
||||
},
|
||||
session({ session, token, user }) {
|
||||
session.user = { ...session.user, ...(token.user as any) };
|
||||
return session;
|
||||
}
|
||||
},
|
||||
providers: [CredentialsProvider({
|
||||
name: 'Credentials',
|
||||
credentials: {
|
||||
username: { label: 'Username', type: 'text', placeholder: 'Username' },
|
||||
password: { label: 'Password', type: 'password' }
|
||||
},
|
||||
async authorize({ username, password }, req) {
|
||||
if (typeof username !== 'string' || typeof password !== 'string')
|
||||
return null;
|
||||
|
||||
const user = await db.selectFrom('aime_user as u')
|
||||
.where(({ eb, fn }) =>
|
||||
eb(fn<string>('lower', ['u.username']), '=', username.toLowerCase().trim()))
|
||||
.leftJoin(
|
||||
eb => eb.selectFrom('chuni_profile_data as chuni')
|
||||
.where(({ eb, selectFrom }) => eb('chuni.version', '=', selectFrom('chuni_static_music')
|
||||
.select(({ fn }) => fn.max('version').as('latest'))))
|
||||
.selectAll()
|
||||
.as('chuni'),
|
||||
join => join.onRef('chuni.user', '=', 'u.id'))
|
||||
.select(({ fn }) => [
|
||||
'u.username', 'u.password', 'u.id', 'u.email', 'u.permissions', 'u.created_date', 'u.last_login_date',
|
||||
'u.suspend_expire_time',
|
||||
fn<boolean>('not isnull', ['chuni.id']).as('chuni')
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user?.password || !await bcrypt.compare(password.trim(), user.password))
|
||||
return null;
|
||||
|
||||
const { password: _, ...payload } = user satisfies DBUserPayload;
|
||||
|
||||
return payload as any;
|
||||
}
|
||||
})]
|
||||
});
|
||||
19
src/components/back-button.tsx
Normal file
19
src/components/back-button.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { Button, ButtonProps } from '@nextui-org/react';
|
||||
|
||||
type BackButtonProps = Partial<ButtonProps> & {
|
||||
referer?: string | null
|
||||
};
|
||||
|
||||
export const BackButton = ({ children, referer, ...props }: BackButtonProps) => {
|
||||
if (referer === null)
|
||||
return null;
|
||||
|
||||
if (referer && globalThis.location && globalThis.location?.origin !== new URL(referer).origin)
|
||||
return null;
|
||||
|
||||
return (<Button {...props} onClick={() => history.back()}>
|
||||
{children}
|
||||
</Button>);
|
||||
};
|
||||
13
src/components/client-providers.tsx
Normal file
13
src/components/client-providers.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { NextUIProvider } from '@nextui-org/react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
|
||||
export function ClientProviders({ children }: { children: ReactNode }) {
|
||||
return (<NextUIProvider className="h-full">
|
||||
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
</NextUIProvider>);
|
||||
}
|
||||
69
src/components/login-card.tsx
Normal file
69
src/components/login-card.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Card, CardBody, CardHeader, Checkbox, Divider, Input } from '@nextui-org/react';
|
||||
import { BackButton } from '@/components/back-button';
|
||||
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { login } from '@/actions/auth';
|
||||
import { redirect, useSearchParams } from 'next/navigation';
|
||||
import { useUser } from '@/helpers/use-user';
|
||||
|
||||
export type LoginCardProps = {
|
||||
referer?: string | null,
|
||||
callback?: string | null,
|
||||
initialError?: string | null
|
||||
};
|
||||
|
||||
export const LoginCard = ({ initialError, referer, callback }: LoginCardProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(initialError ? 'You must be logged in to do that.' : '');
|
||||
const user = useUser();
|
||||
|
||||
if (user)
|
||||
return redirect(callback ?? '/');
|
||||
const submit = (form: FormData) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
login({
|
||||
username: form.get('username') as string,
|
||||
password: form.get('password') as string,
|
||||
redirectTo: callback
|
||||
})
|
||||
.then(res => {
|
||||
if (res?.error)
|
||||
setError(res.message)
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (<Card className="mb-10 w-96 max-w-full">
|
||||
<CardHeader>
|
||||
<BackButton isIconOnly variant="ghost" referer={referer}>
|
||||
<ArrowLeftIcon className="h-5" />
|
||||
</BackButton>
|
||||
<div className="font-bold text-lg ml-auto mx-2 my-1">Login</div>
|
||||
</CardHeader>
|
||||
<Divider />
|
||||
<CardBody>
|
||||
<form className="flex flex-col" action={submit}>
|
||||
<Input type="text" name="username" isRequired label="Username" placeholder="Enter username" className="mb-3" />
|
||||
<Input type="password" name="password" isRequired label="Password" placeholder="Enter password" className="mb-3" />
|
||||
<div className="flex mb-3">
|
||||
<Link href={callback ?
|
||||
`/auth/register?callbackUrl=${encodeURIComponent(callback)}` :
|
||||
'/auth/register'}
|
||||
className="underline text-sm mr-2 ml-auto text-gray-400">
|
||||
Register account
|
||||
</Link>
|
||||
</div>
|
||||
{error && <div className="mb-2 text-danger text-center">
|
||||
{error}
|
||||
</div>}
|
||||
<Button type="submit" color="primary" disabled={loading}>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>);
|
||||
};
|
||||
10
src/components/providers.tsx
Normal file
10
src/components/providers.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { CookiesProvider } from 'next-client-cookies/server';
|
||||
import { auth } from '@/auth';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export async function Providers({ children }: { children: ReactNode }) {
|
||||
return (<SessionProvider session={(await auth())} basePath={process.env.NEXT_PUBLIC_BASE_PATH + '/api/auth'}>
|
||||
{children}
|
||||
</SessionProvider>);
|
||||
}
|
||||
73
src/components/register-card.tsx
Normal file
73
src/components/register-card.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Card, CardBody, CardHeader, Divider, Input } from '@nextui-org/react';
|
||||
import { BackButton } from '@/components/back-button';
|
||||
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useUser } from '@/helpers/use-user';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { register } from '@/actions/auth';
|
||||
|
||||
export type RegisterCardProps = {
|
||||
callback?: string | null,
|
||||
};
|
||||
|
||||
export const RegisterCard = ({ callback }: RegisterCardProps) => {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const user = useUser();
|
||||
|
||||
if (user)
|
||||
return redirect(callback ?? '/');
|
||||
const submit = (data: FormData) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
console.log(data);
|
||||
register(data)
|
||||
.then(({ error, message }) => {
|
||||
if (!error)
|
||||
return setSuccess(true);
|
||||
setError(message ?? 'Unknown error occurred');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (<Card className="mb-10 w-96 max-w-full">
|
||||
<CardHeader>
|
||||
<BackButton isIconOnly variant="ghost">
|
||||
<ArrowLeftIcon className="h-5" />
|
||||
</BackButton>
|
||||
<div className="font-bold text-lg ml-auto mx-2 my-1">Register</div>
|
||||
</CardHeader>
|
||||
<Divider />
|
||||
<CardBody>
|
||||
<form className="flex flex-col" action={submit}>
|
||||
<Input type="text" name="username" isRequired label="Username" placeholder="Enter username" className="mb-3" />
|
||||
<Input type="email" name="email" isRequired label="Email" placeholder="Enter email" className="mb-3" />
|
||||
<Input type="password" name="password" minLength={8} isRequired label="Password" placeholder="Enter password" className="mb-3" />
|
||||
<Input type="password" name="password2" minLength={8} isRequired label="Confirm Password" placeholder="Re-enter password" className="mb-3" />
|
||||
<Input type="text" inputMode="numeric" maxLength={20} name="accessCode" isRequired pattern="\d{20}"
|
||||
label="Access Code" placeholder="Enter code" className="mb-3" title="Enter valid 20 digit code" />
|
||||
<div className="flex mb-3">
|
||||
<Link href={callback ?
|
||||
`/auth/login?callbackUrl=${encodeURIComponent(callback)}` :
|
||||
'/auth/login'}
|
||||
className="underline text-sm mr-2 ml-auto text-gray-400">
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
{error && <div className="mb-2 text-danger text-center">
|
||||
{error}
|
||||
</div>}
|
||||
{success && <div className="mb-2 text-success text-center">
|
||||
Success! You may now log in.
|
||||
</div>}
|
||||
<Button type="submit" color="primary" disabled={loading}>
|
||||
Register
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>);
|
||||
};
|
||||
39
src/components/theme-switcher.tsx
Normal file
39
src/components/theme-switcher.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Switch } from '@nextui-org/react';
|
||||
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline';
|
||||
import { useIsMounted } from 'usehooks-ts';
|
||||
|
||||
export function ThemeSwitcherDropdown() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button variant="bordered" isIconOnly size="sm">
|
||||
<MoonIcon className="w-5" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => setTheme('light')}>
|
||||
Light
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>);
|
||||
}
|
||||
|
||||
export function ThemeSwitcherSwitch() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const mounted = useIsMounted();
|
||||
if (!mounted()) return null;
|
||||
|
||||
return (<Switch size="lg" isSelected={theme === 'dark'} thumbIcon={({ isSelected, className }) => isSelected ?
|
||||
<MoonIcon className={className} /> :
|
||||
<SunIcon className={className} /> } onChange={ev => setTheme(ev.target.checked ? 'dark' : 'light')} />);
|
||||
}
|
||||
19
src/helpers/use-user.ts
Normal file
19
src/helpers/use-user.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { redirect, usePathname } from 'next/navigation';
|
||||
import { UserPayload } from '@/types/user';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
type UseUserProps<R extends boolean> = {
|
||||
required?: R
|
||||
};
|
||||
|
||||
export const useUser = <R extends boolean>({ required }: UseUserProps<R> = {}): R extends true ? UserPayload : UserPayload | null | undefined => {
|
||||
const session = useSession({
|
||||
required: required ?? false
|
||||
});
|
||||
const path = usePathname();
|
||||
|
||||
if (required && !session.data?.user)
|
||||
return redirect(`/auth/login?error=1&callbackUrl=${encodeURIComponent(process.env.NEXT_PUBLIC_BASE_PATH + path)}`);
|
||||
|
||||
return session.data?.user as UserPayload;
|
||||
};
|
||||
3
src/types/page.ts
Normal file
3
src/types/page.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export type PageProps = {
|
||||
searchParams?: Record<string, string | string[]>
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user