Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ FRONTEND_URL=http://localhost:5173
# Add a random string here
# In order to generate one, you may use this command: openssl rand -base64 32
BETTER_AUTH_SECRET=

GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
16 changes: 15 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,21 @@ brew install postgresql@18

### Setting the environment variables

Copy the [.env.example](./.env.example) and replace the `DATABASE_URL` if needed.
Start by copying the [.env.example](./.env.example) to `.env`:

```sh
cd backend
cp .env.example .env
```

Proceed to do the following:

- Check whether the `DATABASE_URL` needs to be updated for the deployment
- Make sure the URLs for the frontend and backend are correct
- Create and insert the GitHub client ID and client secret:
- This can be done by creating an OAuth application in [GitHub's Developer settings -> OAuth Apps](https://github.com/settings/developers)
- Create and insert the Discord client ID and client secret:
- This can be done by creating an application in [Discord's Developer Portal](https://discord.com/developers/applications)

### Downloading the book dataset

Expand Down
10 changes: 10 additions & 0 deletions backend/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,15 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID ?? "",
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID ?? "",
clientSecret: process.env.DISCORD_CLIENT_SECRET ?? "",
},
},
trustedOrigins: [process.env.FRONTEND_URL ?? "http://localhost:5173"],
});
2 changes: 2 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
VITE_APP_URL=http://localhost:5173

# Must be changed to http://it2810-37.idi.ntnu.no:3001/ for production
VITE_API_URL = http://localhost:3001
18 changes: 18 additions & 0 deletions frontend/app/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
import { Link } from "react-router";
import { authClient } from "~/api/auth";
import { Button, buttonVariants } from "~/components/ui/Button";

function Header() {
const { data: session, isPending } = authClient.useSession();

return (
<header className="flex items-center justify-between border-b-2 border-zinc-800 bg-white px-10 py-3.75">
<h1 className="m-0 text-2xl">Readr</h1>
{!isPending && session ? (
<div className="flex items-center gap-4">
<p>{session.user.name}</p>
<Button variant="outline" onClick={() => void authClient.signOut()}>
Log out
</Button>
</div>
) : (
<Link to="/login" className={buttonVariants({ variant: "outline" })}>
Log in
</Link>
)}
</header>
);
}
Expand Down
50 changes: 50 additions & 0 deletions frontend/app/components/SocialLogIns/SocialLogIns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/Collapsible";
import { ChevronDown, ChevronUp } from "lucide-react";
import { SiDiscord } from "@icons-pack/react-simple-icons";
import { Button } from "~/components/ui/Button";
import { useState } from "react";
import { authClient } from "~/api/auth";

function SocialLogIns({ logInType }: { logInType: "login" | "signup" }) {
const [collapsibleOpen, setCollapsibleOpen] = useState(false);

return (
<Collapsible
className="space-y-4"
onOpenChange={(value) => setCollapsibleOpen(value)}
>
<div className="flex justify-center items-center gap-4 font-semibold">
More options
<CollapsibleTrigger>
<Button variant="outline">
{collapsibleOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="block">
<Button
onClick={() =>
void authClient.signIn.social({
provider: "discord",
callbackURL: `${import.meta.env.VITE_APP_URL}/project2/`,
})
}
variant="outline"
>
<SiDiscord className="mr-2 h-4 w-4" />
{logInType === "login" ? "Log in" : "Sign up"} with Discord
</Button>
</CollapsibleContent>
</Collapsible>
);
}

export { SocialLogIns };
2 changes: 1 addition & 1 deletion frontend/app/components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";

const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none cursor-pointer focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
Expand Down
19 changes: 19 additions & 0 deletions frontend/app/components/ui/Collapsible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"

function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}

function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}

function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}

export { Collapsible, CollapsibleTrigger, CollapsibleContent }
2 changes: 2 additions & 0 deletions frontend/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ import { index, route, type RouteConfig } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("book/:bookId", "routes/book/selectedBook.tsx"),
route("signup", "routes/signup.tsx"),
route("login", "routes/login.tsx"),
] satisfies RouteConfig;
177 changes: 177 additions & 0 deletions frontend/app/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { authClient } from "~/api/auth";
import { Button } from "~/components/ui/Button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/Card";
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "~/components/ui/Field";
import { Input } from "~/components/ui/Input";
import { Spinner } from "~/components/ui/Spinner";
import { toast } from "~/components/ui/Toast";
import { SiGithub } from "@icons-pack/react-simple-icons";
import { SocialLogIns } from "~/components/SocialLogIns/SocialLogIns";

const formSchema = z.object({
email: z.email("Enter a valid email address."),
password: z.string().min(1, "Enter your password."),
});

export function meta() {
return [{ title: "Log in | Readr" }];
}

export default function LoginPage() {
const navigate = useNavigate();
const [serverError, setServerError] = useState<string | null>(null);

const form = useForm({
defaultValues: {
email: "",
password: "",
},
validators: {
onSubmit: formSchema,
},
onSubmit: async ({ value }) => {
setServerError(null);
const { error, data } = await authClient.signIn.email(value);

if (error) {
setServerError(error.message ?? "Could not log in. Please try again.");
return;
}

toast.add({
description: `Welcome, ${data.user.name}! You've signed in successfully.`,
type: "success",
});

await navigate("/");
},
});

return (
<main className="grid place-items-center px-4 py-20">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>
<h1 className="text-xl">Log in</h1>
</CardTitle>
<CardDescription>
Enter your email and password to log in.
</CardDescription>
</CardHeader>
<CardContent>
<form
id="login-form"
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
>
<FieldGroup>
<form.Field name="email">
{(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
autoComplete="email"
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
</form.Field>
<form.Field name="password">
{(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
autoComplete="current-password"
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
);
}}
</form.Field>
<FieldError>{serverError}</FieldError>
</FieldGroup>
</form>
</CardContent>
<CardFooter className="flex-col gap-4">
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<Button
type="submit"
form="login-form"
size="lg"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && <Spinner aria-hidden="true" />}
Log in
</Button>
)}
</form.Subscribe>
<Button
onClick={() =>
void authClient.signIn.social({
provider: "github",
callbackURL: `${import.meta.env.VITE_APP_URL}/project2/`,
})
}
variant="outline"
>
<SiGithub className="mr-2 h-4 w-4" />
Log in with GitHub
</Button>
<SocialLogIns logInType="login" />
<p className="text-muted-foreground">
Don't have an account?{" "}
<Link to="/signup" className="text-primary underline">
Sign up
</Link>
</p>
</CardFooter>
</Card>
</main>
);
}
Loading