feat: add credentials authentication (#1)

This commit is contained in:
Meier Lukas
2023-12-10 17:12:20 +01:00
committed by GitHub
parent 41e54d940b
commit 3cedb7fba5
53 changed files with 890 additions and 2105 deletions

View File

@@ -1,7 +0,0 @@
AUTH_SECRET=""
AUTH_DISCORD_ID=""
AUTH_DISCORD_SECRET=""
AUTH_REDIRECT_PROXY_URL=""
NITRO_PRESET="vercel_edge"

View File

@@ -1,16 +0,0 @@
# Auth Proxy
This is a simple proxy server that enables OAuth authentication for preview environments.
## Setup
Deploy it somewhere (Vercel is a one-click, zero-config option) and set the following environment variables:
- `AUTH_DISCORD_ID` - The Discord OAuth client ID
- `AUTH_DISCORD_SECRET` - The Discord OAuth client secret
- `AUTH_REDIRECT_PROXY_URL` - The URL of this proxy server
- `AUTH_SECRET` - Your secret
Make sure the `AUTH_SECRET` and `AUTH_REDIRECT_PROXY_URL` match the values set for the main application's deployment for preview environments, and that you're using the same OAuth credentials for the proxy and the application's preview environment. The lines below shows what values should match eachother in both deployments.
![Environment variables setup](https://github.com/t3-oss/create-t3-turbo/assets/51714798/5fadd3f5-f705-459a-82ab-559a3df881d0)

View File

@@ -1,32 +0,0 @@
{
"name": "@alparr/auth-proxy",
"private": true,
"type": "module",
"scripts": {
"build": "nitro build",
"clean": "rm -rf .turbo node_modules",
"dev": "nitro dev --port 3001",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@auth/core": "^0.18.4"
},
"devDependencies": {
"@alparr/eslint-config": "workspace:^0.2.0",
"@alparr/prettier-config": "workspace:^0.1.0",
"@alparr/tsconfig": "workspace:^0.1.0",
"eslint": "^8.53.0",
"nitropack": "^2.8.1",
"prettier": "^3.1.0",
"typescript": "^5.3.3"
},
"eslintConfig": {
"root": true,
"extends": [
"@alparr/eslint-config/base"
]
},
"prettier": "@alparr/prettier-config"
}

View File

@@ -1,17 +0,0 @@
import { Auth } from "@auth/core";
import Discord from "@auth/core/providers/discord";
import { eventHandler, toWebRequest } from "h3";
export default eventHandler(async (event) =>
Auth(toWebRequest(event), {
secret: process.env.AUTH_SECRET,
trustHost: !!process.env.VERCEL,
redirectProxyUrl: process.env.AUTH_REDIRECT_PROXY_URL,
providers: [
Discord({
clientId: process.env.AUTH_DISCORD_ID,
clientSecret: process.env.AUTH_DISCORD_SECRET,
}),
],
}),
);

View File

@@ -1,4 +0,0 @@
{
"extends": "@alparr/tsconfig/base.json",
"include": ["routes"]
}

View File

@@ -6,12 +6,18 @@ import "@alparr/auth/env.mjs";
const config = {
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */
transpilePackages: ["@alparr/api", "@alparr/auth", "@alparr/db", "@alparr/ui"],
transpilePackages: [
"@alparr/api",
"@alparr/auth",
"@alparr/db",
"@alparr/ui",
"@alparr/validation",
],
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
experimental: {
optimizePackageImports: ['@mantine/core', '@mantine/hooks'],
optimizePackageImports: ["@mantine/core", "@mantine/hooks"],
},
};

View File

@@ -16,7 +16,8 @@
"@alparr/api": "workspace:^0.1.0",
"@alparr/auth": "workspace:^0.1.0",
"@alparr/db": "workspace:^0.1.0",
"@alparr/ui": "workspace:^",
"@alparr/ui": "workspace:^0.1.0",
"@alparr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.3.1",
"@mantine/dates": "^7.3.1",
"@mantine/form": "^7.3.1",
@@ -65,4 +66,4 @@
]
},
"prettier": "@alparr/prettier-config"
}
}

View File

@@ -1,14 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em",
},
},
},
};
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -1,3 +1,14 @@
export { GET, POST } from "@alparr/auth";
import type { NextRequest } from "next/server";
export const runtime = "edge";
import { createHandlers } from "@alparr/auth";
export const GET = async (req: NextRequest) => {
return await createHandlers(isCredentialsRequest(req)).handlers.GET(req);
};
export const POST = async (req: NextRequest) => {
return await createHandlers(isCredentialsRequest(req)).handlers.POST(req);
};
const isCredentialsRequest = (req: NextRequest) => {
return req.url.includes("credentials") && req.method === "POST";
};

View File

@@ -3,8 +3,6 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter, createTRPCContext } from "@alparr/api";
import { auth } from "@alparr/auth";
export const runtime = "edge";
/**
* Configure basic CORS headers
* You should extend this to match your needs

View File

@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Alert,
Button,
PasswordInput,
rem,
Stack,
TextInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { z } from "zod";
import { signIn } from "@alparr/auth/client";
import { signInSchema } from "@alparr/validation";
export const LoginForm = () => {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
const form = useForm<FormType>({
validate: zodResolver(signInSchema),
initialValues: {
name: "",
password: "",
},
});
const handleSubmit = async (values: FormType) => {
setIsLoading(true);
setError(undefined);
await signIn("credentials", {
...values,
redirect: false,
callbackUrl: "/",
})
.then((response) => {
if (!response?.ok) {
throw response?.error;
}
void router.push("/");
})
.catch((error: Error | string) => {
setIsLoading(false);
setError(error.toString());
});
};
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((v) => void handleSubmit(v))}>
<Stack gap="lg">
<TextInput label="Username" {...form.getInputProps("name")} />
<PasswordInput label="Password" {...form.getInputProps("password")} />
<Button type="submit" fullWidth loading={isLoading}>
Login
</Button>
</Stack>
</form>
{error && (
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red">
{error}
</Alert>
)}
</Stack>
);
};
type FormType = z.infer<typeof signInSchema>;

View File

@@ -0,0 +1,25 @@
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { LogoWithTitle } from "~/components/layout/logo";
import { LoginForm } from "./_components/login-form";
export default function Login() {
return (
<Center>
<Stack align="center" mt="xl">
<LogoWithTitle />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
Log in to your account
</Title>
<Text size="sm" c="gray.5" ta="center">
Welcome back! Please enter your credentials
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<LoginForm />
</Card>
</Stack>
</Center>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import type { z } from "zod";
import { initUserSchema } from "@alparr/validation";
import { showErrorNotification, showSuccessNotification } from "~/notification";
import { api } from "~/utils/api";
export const InitUserForm = () => {
const router = useRouter();
const { mutateAsync, error, isPending } = api.user.initUser.useMutation();
const form = useForm<FormType>({
validate: zodResolver(initUserSchema),
validateInputOnBlur: true,
validateInputOnChange: true,
initialValues: {
username: "",
password: "",
repeatPassword: "",
},
});
const handleSubmit = async (values: FormType) => {
console.log(values);
await mutateAsync(values, {
onSuccess: () => {
showSuccessNotification({
title: "User created",
message: "You can now log in",
});
router.push("/auth/login");
},
onError: () => {
showErrorNotification({
title: "User creation failed",
message: error?.message ?? "Unknown error",
});
},
});
};
return (
<Stack gap="xl">
<form
onSubmit={form.onSubmit(
(v) => void handleSubmit(v),
(err) => console.log(err),
)}
>
<Stack gap="lg">
<TextInput label="Username" {...form.getInputProps("username")} />
<PasswordInput label="Password" {...form.getInputProps("password")} />
<PasswordInput
label="Repeat password"
{...form.getInputProps("repeatPassword")}
/>
<Button type="submit" fullWidth loading={isPending}>
Create user
</Button>
</Stack>
</form>
</Stack>
);
};
type FormType = z.infer<typeof initUserSchema>;

View File

@@ -0,0 +1,38 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { db } from "@alparr/db";
import { LogoWithTitle } from "~/components/layout/logo";
import { InitUserForm } from "./_components/init-user-form";
export default async function InitUser() {
const firstUser = await db.query.users.findFirst({
columns: {
id: true,
},
});
if (firstUser) {
return notFound();
}
return (
<Center>
<Stack align="center" mt="xl">
<LogoWithTitle />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
New Alparr installation
</Title>
<Text size="sm" c="gray.5" ta="center">
Please create the initial administator user.
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<InitUserForm />
</Card>
</Stack>
</Center>
);
}

View File

@@ -1,12 +1,14 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/notifications/styles.css";
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
import { headers } from "next/headers";
import { ColorSchemeScript, MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import { uiConfiguration } from "@alparr/ui";
import { TRPCReactProvider } from "./providers";
@@ -29,14 +31,22 @@ export const metadata: Metadata = {
};
export default function Layout(props: { children: React.ReactNode }) {
const colorScheme = "dark";
return (
<html lang="en">
<head>
<ColorSchemeScript />
<ColorSchemeScript defaultColorScheme={colorScheme} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<TRPCReactProvider headers={headers()}>
<MantineProvider defaultColorScheme="dark" {...uiConfiguration}>{props.children}</MantineProvider>
<MantineProvider
defaultColorScheme={colorScheme}
{...uiConfiguration}
>
<Notifications />
{props.children}
</MantineProvider>
</TRPCReactProvider>
</body>
</html>

View File

@@ -1,7 +1,10 @@
import { db } from "@alparr/db";
import { Button, Stack, Title } from "@mantine/core";
import { auth } from "@alparr/auth";
import { db } from "@alparr/db";
export default async function HomePage() {
const currentSession = await auth();
const users = await db.query.users.findMany();
return (
@@ -9,6 +12,11 @@ export default async function HomePage() {
<Title>Home</Title>
<Button>Test</Button>
<pre>{JSON.stringify(users)}</pre>
{currentSession && (
<span>
Currently logged in as <b>{currentSession.user.name}</b>
</span>
)}
</Stack>
);
}

View File

@@ -0,0 +1,17 @@
import Image from "next/image";
import { Group, Title } from "@mantine/core";
interface LogoProps {
size: number;
}
export const Logo = ({ size = 60 }: LogoProps) => (
<Image src="/logo/alparr.png" alt="Alparr logo" width={size} height={size} />
);
export const LogoWithTitle = () => (
<Group gap={0}>
<Logo size={48} />
<Title order={1}>lparr</Title>
</Group>
);

View File

@@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
import type { NotificationData } from "@mantine/notifications";
import { notifications } from "@mantine/notifications";
import { IconCheck, IconX } from "@tabler/icons-react";
type CommonNotificationProps = Pick<NotificationData, "title" | "message">;
export const showSuccessNotification = (props: CommonNotificationProps) =>
notifications.show({
...props,
color: "teal",
icon: <IconCheck size={rem(20)} />,
});
export const showErrorNotification = (props: CommonNotificationProps) =>
notifications.show({
...props,
color: "red",
icon: <IconX size={rem(20)} />,
});