feat: auth works!
This commit is contained in:
parent
45ecac8a48
commit
66796503a5
61
package.json
61
package.json
|
@ -4,62 +4,61 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"db:push": "drizzle-kit push:sqlite",
|
||||
"dev": "next dev",
|
||||
"postinstall": "prisma generate",
|
||||
"lint": "next lint",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/form": "^7.1.7",
|
||||
"@prisma/migrate": "^5.4.1",
|
||||
"@prisma/migrate": "^5.5.2",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@t3-oss/env-nextjs": "^0.6.0",
|
||||
"@tanstack/react-query": "^4.32.6",
|
||||
"@trpc/client": "^10.37.1",
|
||||
"@trpc/next": "^10.37.1",
|
||||
"@trpc/react-query": "^10.37.1",
|
||||
"@trpc/server": "^10.37.1",
|
||||
"argon2": "^0.31.1",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@trpc/client": "^10.43.1",
|
||||
"@trpc/next": "^10.43.1",
|
||||
"@trpc/react-query": "^10.43.1",
|
||||
"@trpc/server": "^10.43.1",
|
||||
"argon2": "^0.31.2",
|
||||
"better-sqlite3": "^9.0.0",
|
||||
"bunyan": "^1.8.15",
|
||||
"bunyan-format": "^0.2.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"drizzle-orm": "^0.28.6",
|
||||
"next": "^13.4.19",
|
||||
"next-auth": "^4.23.2",
|
||||
"next": "^14.0.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"superjson": "^1.13.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"sonner": "^1.2.0",
|
||||
"superjson": "^2.2.0",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-permissions": "^1.0.0",
|
||||
"zod": "^3.21.4"
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.6",
|
||||
"@types/bunyan": "^1.8.9",
|
||||
"@types/bunyan-format": "^0.2.6",
|
||||
"@types/eslint": "^8.44.2",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
||||
"@typescript-eslint/parser": "^6.3.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"@types/bunyan": "^1.8.10",
|
||||
"@types/bunyan-format": "^0.2.7",
|
||||
"@types/eslint": "^8.44.6",
|
||||
"@types/node": "^20.8.10",
|
||||
"@types/react": "^18.2.35",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "^6.9.1",
|
||||
"@typescript-eslint/parser": "^6.9.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"drizzle-kit": "^0.19.13",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-config-next": "^13.4.19",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.1.6"
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-next": "^14.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.6",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.20.3-beta.2e8399f"
|
||||
|
|
1452
pnpm-lock.yaml
1452
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -12,11 +12,32 @@ import {
|
|||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
const [toastLoading, setToastLoading] = useState<
|
||||
string | number | undefined
|
||||
>();
|
||||
|
||||
const login = api.auth.login.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Successfully logged in!", { id: toastLoading });
|
||||
router.push("/dashboard");
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error(error.message, { id: toastLoading });
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
@ -24,35 +45,60 @@ export default function LoginForm() {
|
|||
return (
|
||||
<Card className="mx-auto w-80">
|
||||
<CardHeader>
|
||||
<CardTitle>Hostforge</CardTitle>
|
||||
<CardTitle>🔥 Hostforge</CardTitle>
|
||||
<CardDescription>Log into your account to start.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{/* basic login */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email">Username</Label>
|
||||
<Input id="email" type="email" placeholder="jsmith" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" />
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
id="loginform"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
login.mutate(form.values);
|
||||
setToastLoading(toast.loading("Logging you in..."));
|
||||
}}
|
||||
>
|
||||
{/* basic login */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="jsmith"
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{/* or */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <div className="flex items-center gap-2">
|
||||
<hr className="flex-grow border-gray-300" />
|
||||
<span className="text-gray-500">or</span>
|
||||
<hr className="flex-grow border-gray-300" />
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* social login */}
|
||||
<Button variant="outline">
|
||||
{/* TODO: icons */}
|
||||
GitHub
|
||||
</Button>
|
||||
{/* <Button variant="outline"> */}
|
||||
{/* TODO: icons */}
|
||||
{/* GitHub
|
||||
</Button> */}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full">Log In</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
form="loginform"
|
||||
isLoading={login.isLoading}
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function CreatePost() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const createPost = api.post.create.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
setName("");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createPost.mutate({ name });
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-full px-4 py-2 text-black"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
|
||||
disabled={createPost.isLoading}
|
||||
>
|
||||
{createPost.isLoading ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -10,12 +10,12 @@ const handler = (req: NextRequest) =>
|
|||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createTRPCContext({ req }),
|
||||
createContext: ({ resHeaders }) => createTRPCContext({ req, resHeaders }),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
|
|
11
src/app/dashboard/RSC.tsx
Normal file
11
src/app/dashboard/RSC.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { api } from "~/trpc/server";
|
||||
|
||||
export default async function Test() {
|
||||
const user = await api.auth.me.query();
|
||||
|
||||
return (
|
||||
<div>
|
||||
Logged in as {user.username} ({user.id})
|
||||
</div>
|
||||
);
|
||||
}
|
11
src/app/dashboard/page.tsx
Normal file
11
src/app/dashboard/page.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { api } from "~/trpc/server";
|
||||
import Test from "./RSC";
|
||||
|
||||
export default async function DashboardHome() {
|
||||
return (
|
||||
<div>
|
||||
rip
|
||||
<Test />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,9 +1,10 @@
|
|||
import "~/styles/globals.css";
|
||||
|
||||
import { Inter } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
import { ThemeProvider } from "~/components/ThemeProvider";
|
||||
import { ThemeProvider } from "~/components/contexts/ThemeProvider";
|
||||
import { ToastProvider } from "~/components/contexts/ToastProvider";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
|
@ -24,9 +25,9 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en">
|
||||
<body className={`font-sans ${inter.variable} min-h-screen min-w-full`}>
|
||||
<TRPCReactProvider headers={headers()}>
|
||||
<TRPCReactProvider cookies={cookies().toString()}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</TRPCReactProvider>
|
||||
</body>
|
||||
|
|
106
src/app/setup/SetupForm.tsx
Normal file
106
src/app/setup/SetupForm.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Required } from "~/components/ui/required";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function SetupForm() {
|
||||
const router = useRouter();
|
||||
|
||||
// setup mutation
|
||||
const [toastLoading, setToastLoading] = useState<
|
||||
string | number | undefined
|
||||
>();
|
||||
const setupInstance = api.setup.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push("/dashboard");
|
||||
toast.success("Successfully setup instance!", { id: toastLoading });
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastLoading });
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
|
||||
validate: {
|
||||
password: (value) => value.length === 0 && "Password is required",
|
||||
username: (value) => value.length === 0 && "Username is required",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="m-auto h-fit max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Setup Hostforge 🚀</CardTitle>
|
||||
<CardDescription>
|
||||
Welcome to Hostforge — a modern self-hosted platform for deploying and
|
||||
managing your own applications.
|
||||
<br /> <br /> To start, please enter your desired username and
|
||||
password for the administrator account. You can setup two factor
|
||||
authentication later.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
id="setupform"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.onSubmit((data) => {
|
||||
setToastLoading(toast.loading("Setting up instance..."));
|
||||
setupInstance.mutate(data);
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">
|
||||
Username
|
||||
<Required />
|
||||
</Label>
|
||||
<Input {...form.getInputProps("username")} id="username" />
|
||||
{form.errors.username && (
|
||||
<div className="text-red-500">{form.errors.username}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">
|
||||
Password
|
||||
<Required />
|
||||
</Label>
|
||||
<Input
|
||||
{...form.getInputProps("password")}
|
||||
id="password"
|
||||
type="password"
|
||||
/>
|
||||
{form.errors.password && (
|
||||
<div className="text-red-500">{form.errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" type="submit" form="setupform">
|
||||
Submit
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1 +1,9 @@
|
|||
export default function SetupInstance() {}
|
||||
import { SetupForm } from "./SetupForm";
|
||||
|
||||
export default function SetupInstance() {
|
||||
return (
|
||||
<div className="min-w-screen flex min-h-screen justify-center p-4 align-middle">
|
||||
<SetupForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
21
src/components/contexts/ToastProvider.tsx
Normal file
21
src/components/contexts/ToastProvider.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { type ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster
|
||||
position="bottom-center"
|
||||
richColors
|
||||
theme={theme.resolvedTheme === "dark" ? "dark" : "light"}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "~/utils/utils";
|
||||
import { CgSpinner } from "react-icons/cg";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
@ -38,17 +38,36 @@ export interface ButtonProps
|
|||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
children,
|
||||
isLoading,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
isLoading && "cursor-not-allowed brightness-75 filter",
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
disabled={isLoading || props.disabled}
|
||||
>
|
||||
{isLoading && <CgSpinner className="mr-2 animate-spin" />}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -2,15 +2,18 @@ import * as React from "react";
|
|||
|
||||
import { cn } from "~/utils/utils";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
error?: boolean | string;
|
||||
};
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
({ className, type, error, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
error && "border-red-500 focus-visible:ring-red-500",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
1
src/components/ui/required.tsx
Normal file
1
src/components/ui/required.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export const Required = () => <span className="px-1 text-red-500">*</span>;
|
|
@ -7,6 +7,17 @@ export async function register() {
|
|||
const logger = (await import("./server/utils/logger")).default;
|
||||
const { migrate } = await import("drizzle-orm/better-sqlite3/migrator");
|
||||
const { db } = await import("./server/db");
|
||||
const { mkdir, stat } = await import("fs/promises");
|
||||
const { dirname } = await import("path");
|
||||
|
||||
// check if database folder exists
|
||||
try {
|
||||
const dir = dirname(env.DATABASE_PATH);
|
||||
await stat(dir);
|
||||
} catch (e) {
|
||||
await mkdir(dirname(env.DATABASE_PATH), { recursive: true });
|
||||
logger.debug(`Created database folder ${dirname(env.DATABASE_PATH)}`);
|
||||
}
|
||||
|
||||
if (env.NODE_ENV === "production") {
|
||||
logger.child({ module: "database" }).info("⚙️ Migrating database");
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { postRouter } from "~/server/api/routers/post";
|
||||
import { createTRPCRouter } from "~/server/api/trpc";
|
||||
import { setupProcedure } from "./routers/setup";
|
||||
import { authRouter } from "./routers/auth";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
|
@ -7,7 +8,8 @@ import { createTRPCRouter } from "~/server/api/trpc";
|
|||
* All routers added in /api/routers should be manually added here.
|
||||
*/
|
||||
export const appRouter = createTRPCRouter({
|
||||
post: postRouter,
|
||||
setup: setupProcedure,
|
||||
auth: authRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
|
|
@ -64,7 +64,7 @@ export const authRouter = createTRPCRouter({
|
|||
}
|
||||
|
||||
const session = await Session.createForUser(user.id, ctx.request);
|
||||
ctx.request.cookies.set("session", session.data.token);
|
||||
ctx.headers.set("Set-Cookie", session.getCookieString());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
|
||||
export const postRouter = createTRPCRouter({
|
||||
hello: publicProcedure
|
||||
.input(z.object({ text: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return {
|
||||
greeting: `Hello ${input.text}`,
|
||||
};
|
||||
}),
|
||||
|
||||
create: publicProcedure
|
||||
.input(z.object({ name: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// simulate a slow db call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
return ctx.db.post.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
getLatest: publicProcedure.query(({ ctx }) => {
|
||||
return ctx.db.post.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}),
|
||||
});
|
|
@ -7,12 +7,13 @@
|
|||
* need to use are documented accordingly near the end.
|
||||
*/
|
||||
import { TRPCError, initTRPC } from "@trpc/server";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { Session } from "../auth/Session";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
|
@ -52,9 +53,12 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
|||
*
|
||||
* @see https://trpc.io/docs/context
|
||||
*/
|
||||
export const createTRPCContext = async (opts: { req: NextRequest }) => {
|
||||
export const createTRPCContext = async (opts: {
|
||||
req: NextRequest;
|
||||
resHeaders: Headers;
|
||||
}) => {
|
||||
// resolve session data
|
||||
const sessionToken = opts.req.cookies.get("session")?.value;
|
||||
const sessionToken = opts.req.cookies.get("sessionToken")?.value;
|
||||
|
||||
const session: Session | null = sessionToken
|
||||
? await Session.fetchFromTokenAndUpdate(sessionToken, {
|
||||
|
@ -64,7 +68,7 @@ export const createTRPCContext = async (opts: { req: NextRequest }) => {
|
|||
: null;
|
||||
|
||||
return createInnerTRPCContext({
|
||||
headers: opts.req.headers,
|
||||
headers: opts.resHeaders,
|
||||
session,
|
||||
request: opts.req,
|
||||
});
|
||||
|
|
|
@ -3,7 +3,7 @@ import { db } from "../db";
|
|||
import { users, sessions } from "../db/schema";
|
||||
import { randomBytes } from "crypto";
|
||||
import assert from "assert";
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
|
||||
export type SessionUpdateData = Partial<{
|
||||
ua: string;
|
||||
|
@ -11,6 +11,12 @@ export type SessionUpdateData = Partial<{
|
|||
}>;
|
||||
|
||||
export class Session {
|
||||
/**
|
||||
* The length of time a session should last.
|
||||
* currently 30 days.
|
||||
*/
|
||||
static readonly EXPIRE_TIME = 1000 * 60 * 60 * 24 * 30;
|
||||
|
||||
/**
|
||||
* Fetch a session from a session token.
|
||||
* @param token The session cookie
|
||||
|
@ -74,7 +80,7 @@ export class Session {
|
|||
|
||||
// parse context
|
||||
const parsedContext =
|
||||
context instanceof NextRequest
|
||||
context instanceof Request
|
||||
? Session.getContextFromRequest(context)
|
||||
: context;
|
||||
|
||||
|
@ -98,7 +104,7 @@ export class Session {
|
|||
*/
|
||||
static getContextFromRequest(request: NextRequest) {
|
||||
return {
|
||||
ua: request.headers.get("user-agent") ?? undefined,
|
||||
ua: userAgent(request).ua ?? undefined,
|
||||
ip: request.ip,
|
||||
} satisfies SessionUpdateData;
|
||||
}
|
||||
|
@ -134,4 +140,14 @@ export class Session {
|
|||
async delete() {
|
||||
await db.delete(sessions).where(eq(sessions.token, this.data.token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cookie string for this session.
|
||||
*/
|
||||
getCookieString() {
|
||||
const expire = new Date(this.data.createdAt + Session.EXPIRE_TIME);
|
||||
return `sessionToken=${
|
||||
this.data.token
|
||||
}; Expires=${expire.toUTCString()}; Path=/; HttpOnly; SameSite=Strict`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,12 @@ sqlite.pragma("journal_mode = WAL");
|
|||
// load uuidv7 extension
|
||||
// built from https://github.com/craigpastro/sqlite-uuidv7
|
||||
sqlite.loadExtension(
|
||||
env.SQLITE_UUIDV7_EXT_PATH ?? join(__dirname, "../../exts/sqlite-uuidv7"),
|
||||
env.SQLITE_UUIDV7_EXT_PATH ??
|
||||
join(
|
||||
// cannot use __dirname since this file will change locations when compiled
|
||||
process.cwd(),
|
||||
"./exts/sqlite-uuidv7",
|
||||
),
|
||||
);
|
||||
|
||||
export const db = drizzle(sqlite);
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
|
||||
// util
|
||||
const uuidv7 = sql`(uuid_generate_v7())`;
|
||||
const now = sql`CURRENT_TIMESTAMP`;
|
||||
const now = sql<number>`CURRENT_TIMESTAMP`;
|
||||
|
||||
/**
|
||||
* User table.
|
||||
|
@ -45,7 +45,7 @@ export const sessions = sqliteTable("session", {
|
|||
lastIP: text("last_ip"),
|
||||
// NOT IN MILLISECONDS!
|
||||
lastAccessed: integer("last_accessed", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at").default(now),
|
||||
createdAt: integer("created_at").default(now).notNull(),
|
||||
userId: text("id").notNull(),
|
||||
});
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
|
@ -9,63 +9,65 @@
|
|||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--background: 22.5 66.67% 2.35%;
|
||||
--foreground: 20 100% 94.12%;
|
||||
|
||||
--primary: 19.5 100% 60.78%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--card: 22.5 66.67% 2.35%;
|
||||
--card-foreground: 20 100% 94.12%;
|
||||
|
||||
--popover: 22.5 66.67% 2.35%;
|
||||
--popover-foreground: 20 100% 94.12%;
|
||||
|
||||
--secondary: 19.71 64.81% 21.18%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
|
||||
--muted: 25.71 11.48% 11.96%;
|
||||
--muted-foreground: 15 1.61% 51.37%;
|
||||
|
||||
--accent: 19.71 64.81% 21.18%;
|
||||
--accent-foreground: 20 100% 94.12%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 19.5 100% 60.78%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
|
@ -73,4 +75,4 @@
|
|||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
25
src/trpc/auth.ts
Normal file
25
src/trpc/auth.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { TRPCLink } from "@trpc/client";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { AppRouter } from "~/server/api/root";
|
||||
import { httpLink } from "@trpc/client/links/httpLink";
|
||||
|
||||
type LinkOptions = Parameters<typeof httpLink>[0];
|
||||
|
||||
/**
|
||||
* Simple re-implementation of httpLink, except it only acts as a terminating link for requests to *auth* routes.
|
||||
* Cannot set cookies in a streaming response, but we don't want to require every request to be a non-streaming batch request, so this is the best option.
|
||||
*/
|
||||
export const authLink: (opts: LinkOptions) => TRPCLink<AppRouter> = (opts) => {
|
||||
const originalLink = httpLink(opts);
|
||||
|
||||
return (runtime) =>
|
||||
({ next, op }) => {
|
||||
return observable((observer) => {
|
||||
if (!op.path.startsWith("auth.")) {
|
||||
return next(op).subscribe(observer);
|
||||
}
|
||||
|
||||
originalLink(runtime)({ next, op }).subscribe(observer);
|
||||
});
|
||||
};
|
||||
};
|
|
@ -1,18 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
|
||||
import {
|
||||
HTTPBatchStreamLinkOptions,
|
||||
loggerLink,
|
||||
unstable_httpBatchStreamLink,
|
||||
} from "@trpc/client";
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { type AppRouter } from "~/server/api/root";
|
||||
import { getUrl, transformer } from "./shared";
|
||||
import { authLink } from "./auth";
|
||||
|
||||
export const api = createTRPCReact<AppRouter>();
|
||||
|
||||
const sharedLinkOptions = (cookies: string) =>
|
||||
({
|
||||
url: getUrl(),
|
||||
headers() {
|
||||
return {
|
||||
cookie: cookies,
|
||||
"x-trpc-source": "react",
|
||||
};
|
||||
},
|
||||
|
||||
fetch(url, options) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
}) satisfies HTTPBatchStreamLinkOptions;
|
||||
|
||||
export function TRPCReactProvider(props: {
|
||||
children: React.ReactNode;
|
||||
headers: Headers;
|
||||
cookies: string;
|
||||
}) {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
|
||||
|
@ -25,16 +48,10 @@ export function TRPCReactProvider(props: {
|
|||
process.env.NODE_ENV === "development" ||
|
||||
(op.direction === "down" && op.result instanceof Error),
|
||||
}),
|
||||
unstable_httpBatchStreamLink({
|
||||
url: getUrl(),
|
||||
headers() {
|
||||
const heads = new Map(props.headers);
|
||||
heads.set("x-trpc-source", "react");
|
||||
return Object.fromEntries(heads);
|
||||
},
|
||||
}),
|
||||
authLink(sharedLinkOptions(props.cookies)),
|
||||
unstable_httpBatchStreamLink(sharedLinkOptions(props.cookies)),
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -3,10 +3,10 @@ import {
|
|||
loggerLink,
|
||||
unstable_httpBatchStreamLink,
|
||||
} from "@trpc/client";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { type AppRouter } from "~/server/api/root";
|
||||
import { getUrl, transformer } from "./shared";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const api = createTRPCProxyClient<AppRouter>({
|
||||
transformer,
|
||||
|
@ -19,9 +19,10 @@ export const api = createTRPCProxyClient<AppRouter>({
|
|||
unstable_httpBatchStreamLink({
|
||||
url: getUrl(),
|
||||
headers() {
|
||||
const heads = new Map(headers());
|
||||
heads.set("x-trpc-source", "rsc");
|
||||
return Object.fromEntries(heads);
|
||||
return {
|
||||
cookie: cookies().toString(),
|
||||
"x-trpc-source": "rsc",
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
|
Loading…
Reference in a new issue