This commit is contained in:
sk1982 2024-03-12 06:50:30 -04:00
parent c4fdb7be58
commit ec582f2eef
14 changed files with 475 additions and 0 deletions

119
src/actions/auth.ts Normal file
View 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 };
};

View 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} />);
}

View 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()} />)
}

View 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>
}

View 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
View 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;
}
})]
});

View 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>);
};

View 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>);
}

View 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>);
};

View 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>);
}

View 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>);
};

View 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
View 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
View File

@ -0,0 +1,3 @@
export type PageProps = {
searchParams?: Record<string, string | string[]>
};