Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supabase ssr auth #1

Merged
merged 8 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
# Add your env variables here
APP_DEPLOYMENT_ENV="staging"
#APP_DEPLOYMENT_ENV="development"

# Connect to Supabase via connection pooling with Supavisor.
DATABASE_URL=op://Shared/supabase-authentication/$APP_DEPLOYMENT_ENV/DATABASE_URL

# Direct connection to the database. Used for migrations.
DIRECT_URL=op://Shared/supabase-authentication/$APP_DEPLOYMENT_ENV/DIRECT_URL

SUPABASE_URL=op://Shared/supabase-authentication/$APP_DEPLOYMENT_ENV/SUPABASE_URL
SUPABASE_API_KEY=op://Shared/supabase-authentication/$APP_DEPLOYMENT_ENV/SUPABASE_API_KEY
SUPABASE_BUCKET=op://Shared/supabase-authentication/$APP_DEPLOYMENT_ENV/SUPABASE_BUCKET
SUPABASE_BUCKET_URL=op://Shared/supabase-authentication/$APP_DEPLOYMENT_ENV/SUPABASE_BUCKET_URL
2 changes: 1 addition & 1 deletion app/components/dashboard/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function Header({ email }: HeaderProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Link to="/profile" className="w-full">
<Link to="/account" className="w-full">
Profile Settings
</Link>
</DropdownMenuItem>
Expand Down
4 changes: 2 additions & 2 deletions app/env.server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { z } from "zod"

const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
APP_DEPLOYMENT_ENV: z.enum(["staging", "production"]),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
APP_DEPLOYMENT_ENV: z.enum(["development", "staging", "production"]).default("development"),
})

type APP_ENV = z.infer<typeof envSchema>
Expand Down
85 changes: 49 additions & 36 deletions app/routes/_auth.login.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Github, Mail, SmartphoneNfc } from "lucide-react"
import { type ActionFunctionArgs, Form, Link, useNavigation } from "react-router"
import { getValidatedFormData } from "remix-hook-form"
import { Github, SmartphoneNfc } from "lucide-react"
import { type ActionFunctionArgs, Form, Link, redirect, useSubmit } from "react-router"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { Separator } from "~/components/ui/separator"

import { loginSchema } from "~/schemas/login-schema"
const resolver = zodResolver(loginSchema)
import { getSupabaseServerClient } from "~/supabase/supabase.server"

//TODO add loader to check if user is already logged in, in that case redirect to dashboard

export async function action({ request }: ActionFunctionArgs) {
const { errors, data, receivedValues: defaultValues } = await getValidatedFormData<FormData>(request, resolver)
if (errors) {
return { errors, defaultValues }
const formData = await request.formData()
const data = Object.fromEntries(formData)
const parsedData = loginSchema.safeParse(data)
if (!parsedData.success) {
return { error: "Invalid input" }
}

const { email, password } = parsedData.data
const headersToSet = new Headers()
const { supabase, headers } = getSupabaseServerClient(request, headersToSet)

const { error } = await supabase.auth.signInWithPassword({
email,
password,
})

if (error) {
return { error }
}
//do something with the data
return { data }

return redirect("/dashboard", {
headers,
})
}

//TODO show form validations
export default function Login() {
const navigation = useNavigation()
const isSubmitting = navigation.state === "submitting"
const submit = useSubmit()
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
Expand All @@ -32,16 +50,11 @@ export default function Login() {
</Link>
</p>
</div>
{/* {actionData?.error && (
<div className="bg-red-50 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<span className="block sm:inline">{actionData.error}</span>
</div>
)} */}
<Form method="POST" className="mt-8 space-y-6">
<div className="space-y-4 rounded-md shadow-sm">
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required className="mt-1" />
<Input id="email" type="email" autoComplete="email" className="mt-1" required name="email" />
</div>
<div>
<Label htmlFor="password">Password</Label>
Expand All @@ -60,25 +73,25 @@ export default function Login() {
Forgot your password?
</Link>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Signing in..." : "Sign in"}
<Button type="submit" className="w-full">
Sign in
</Button>
<Separator text="or" />
<div className="grid gap-2">
<Button variant="outline" className="w-full" onClick={() => {}}>
<SmartphoneNfc className="mr-2 h-4 w-4" />
Continue with OTP
</Button>
<Button variant="outline" className="w-full" onClick={() => {}}>
<Github className="mr-2 h-4 w-4" />
Continue with GitHub
</Button>
<Button variant="outline" className="w-full" onClick={() => {}}>
<Mail className="mr-2 h-4 w-4" />
Continue with Google
</Button>
</div>
</Form>
<Separator text="or" />
<div className="grid gap-2">
<Button variant="outline" className="w-full" onClick={() => {}}>
<SmartphoneNfc className="mr-2 h-4 w-4" />
Continue with OTP
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => submit(null, { action: "/auth/github", method: "POST" })}
>
<Github className="mr-2 h-4 w-4" />
Continue with GitHub
</Button>
</div>
</div>
</div>
)
Expand Down
62 changes: 31 additions & 31 deletions app/routes/_auth.register.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { type ActionFunctionArgs, Form, Link, useNavigation } from "react-router"
import { getValidatedFormData } from "remix-hook-form"
import { type ActionFunctionArgs, Form, Link, redirect, useNavigation } from "react-router"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { registerSchema } from "~/schemas/register-schema"
const resolver = zodResolver(registerSchema)
import { getSupabaseServerClient } from "~/supabase/supabase.server"

//TODO add loader to check if user is already logged in, in that case redirect to dashboard

export async function action({ request }: ActionFunctionArgs) {
const { errors, data, receivedValues: defaultValues } = await getValidatedFormData<FormData>(request, resolver)
if (errors) {
return { errors, defaultValues }
const formData = await request.formData()
const data = Object.fromEntries(formData)
const parsedData = registerSchema.safeParse(data)
if (!parsedData.success) {
return { error: "Invalid input" }
}
//do something with the data
return { data }

const { email, password } = parsedData.data

const headersToSet = new Headers()
const { supabase, headers } = getSupabaseServerClient(request, headersToSet)

const { error: supaError } = await supabase.auth.signUp({
email,
password,
})

if (supaError) {
return { errors: supaError }
}

return redirect("/dashboard", {
headers,
})
}

//TODO show form validations
export default function Register() {
// const actionData = useActionData<typeof action>()
const navigation = useNavigation()
const isSubmitting = navigation.state === "submitting"
return (
Expand All @@ -30,35 +50,15 @@ export default function Register() {
</Link>
</p>
</div>
{/* {actionData?.error && (
<div className="bg-red-50 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<span className="block sm:inline">{actionData.error}</span>
</div>
)} */}
<Form method="post" className="mt-8 space-y-6">
<div className="space-y-4 rounded-md shadow-sm">
<div>
<Label htmlFor="fullName">Full Name</Label>
<Input id="fullName" name="fullName" type="text" required className="mt-1" />
</div>
<div>
<Label htmlFor="username">Username</Label>
<Input id="username" name="username" type="text" required className="mt-1" />
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required className="mt-1" />
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
className="mt-1"
/>
<Input id="password" name="password" type="password" required className="mt-1" />
</div>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
Expand Down
66 changes: 39 additions & 27 deletions app/routes/_dashboard.account.tsx → app/routes/account.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,64 @@
import { type ChangeEvent, useRef } from "react"
import { Form, type LoaderFunctionArgs, useFetcher } from "react-router"
import { Form, type LoaderFunctionArgs, useFetcher, useLoaderData } from "react-router"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { requireUser } from "~/server/guards.server"

// biome-ignore lint/correctness/noUnusedVariables: <explanation>
// biome-ignore lint/correctness/noUnusedFunctionParameters: <explanation>
export async function loader({ request }: LoaderFunctionArgs) {
return null
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { user } = await requireUser(request)
return { user }
}

export default function Account() {
const { user } = useLoaderData<typeof loader>()
const fileInputRef = useRef<HTMLInputElement>(null)
const fetcher = useFetcher()
// biome-ignore lint/correctness/noUnusedVariables: <explanation>

const getInitials = (email: string) => {
return email?.split("@")[0].slice(0, 2).toUpperCase() || "??"
}

const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files
if (selectedFiles && selectedFiles.length > 0) {
const avatar = selectedFiles[0]
// Validate file type
const validImageTypes = ["image/jpeg", "image/png", "image/gif"]
if (!validImageTypes.includes(avatar.type)) {
alert("Please select a valid image file (jpeg, png, gif).")
return
}
const formData = new FormData()
formData.append("avatar", avatar)
fetcher.submit(formData, { method: "POST", action: "/avatar/upload", encType: "multipart/form-data" })
}
}

const selectPhoto = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
// biome-ignore lint/correctness/noUnusedVariables: <explanation>

const removePhoto = () => {
fetcher.submit(null, { method: "POST", action: "/avatar/remove" })
}

return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-black text-white py-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-2xl font-bold">Your Profile</h1>
<p className="my-1 text-blue-100">Manage your profile information</p>
</div>
</div>
{/* Profile Content */}
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 -mt-8">
<div className="bg-white rounded-lg shadow">
<div className="p-8">
{/* Profile Header */}
<div className="flex items-center space-x-4 mb-8">
<Avatar className="h-20 w-20">
<AvatarImage src="" alt="mock-user" />
<AvatarFallback className="text-lg">
{/* {"[email protected]" ? getInitials("dd") : "??"} */}
XY
</AvatarFallback>
<AvatarImage src={user.avatarUrl || undefined} alt="user" />
<AvatarFallback className="text-lg">{user.email ? getInitials(user.email) : "??"}</AvatarFallback>
</Avatar>
<div>
<div className="space-x-2">
<h2 className="text-2xl font-bold text-gray-900">{"Update your profile"}</h2>
<p className="text-gray-500">{"mock-user@example.com"}</p>
<p className="text-gray-500">{user.email}</p>
<Button className="mt-2" onClick={selectPhoto}>
<input
type="file"
Expand All @@ -78,22 +71,41 @@ export default function Account() {
/>
Change photo
</Button>
<Button
className="mt-2 bg-white text-black hover:bg-black/10"
onClick={removePhoto}
disabled={!user.avatarUrl}
>
<input
type="file"
accept="image/*"
ref={fileInputRef}
id="avatar"
name="avatar"
style={{ display: "none" }}
onChange={handleFileChange}
/>
Remove photo
</Button>
</div>
</div>
{/* Profile Form */}
<Form method="post" className="space-y-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<Label htmlFor="lastName">Name</Label>
<Input id="lastName" name="lastName" type="text" defaultValue={"mock-name"} className="mt-1" />
<Input id="lastName" name="lastName" type="text" placeholder="Not provided" className="mt-1" />
</div>
<div>
<Label htmlFor="address">Address</Label>
<Input id="address" name="address" type="text" placeholder="Not provided" className="mt-1" />
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" defaultValue={"mock-user@example.com"} className="mt-1" />
<Input id="email" name="email" type="email" defaultValue={user.email} className="mt-1" />
</div>
<div>
<Label htmlFor="username">Username</Label>
<Input id="username" name="username" type="text" defaultValue={"mock-username"} className="mt-1" />
<Input id="username" name="username" type="text" placeholder="Not provided" className="mt-1" />
</div>
</div>
<div className="flex justify-end pt-4">
Expand Down
20 changes: 20 additions & 0 deletions app/routes/auth.github.callback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type LoaderFunctionArgs, redirect } from "react-router"
import { getSupabaseServerClient } from "~/supabase/supabase.server"

export async function loader({ request }: LoaderFunctionArgs) {
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get("code")
const headersToSet = new Headers()
const { supabase, headers } = getSupabaseServerClient(request, headersToSet)

if (!code) {
return redirect("/login")
}

const { error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
return redirect("/login")
}

throw redirect("/dashboard", { headers })
}
Loading
Loading