add google oauth

main
TZGyn 1 year ago
parent feaf0f3be6
commit 0cd7de59a1
Signed by: TZGyn
GPG Key ID: 122EAF77AE81FD4A

@ -24,3 +24,7 @@ PRIVATE_FLYIO_IPV6=
# Resend config (if you have a Resend hosting provider) # Resend config (if you have a Resend hosting provider)
PRIVATE_RESEND_API_KEY= PRIVATE_RESEND_API_KEY=
# Google OAuth
PRIVATE_GOOGLE_CLIENT_ID=
PRIVATE_GOOGLE_CLIENT_SECRET=

Binary file not shown.

@ -0,0 +1,2 @@
ALTER TABLE "user" ALTER COLUMN "password" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "google_id" varchar(255);

@ -0,0 +1,419 @@
{
"id": "9ba9c83e-b94c-42c5-84c9-010050810026",
"prevId": "ac377af2-68ea-4c36-a69c-4cc57a04a523",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.email_verification_token": {
"name": "email_verification_token",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.project": {
"name": "project",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"uuid": {
"name": "uuid",
"type": "uuid",
"primaryKey": false,
"notNull": false,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"qr_background": {
"name": "qr_background",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true,
"default": "'#ffffff'"
},
"qr_foreground": {
"name": "qr_foreground",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true,
"default": "'#000000'"
},
"domain_status": {
"name": "domain_status",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "'verified'"
},
"enable_custom_domain": {
"name": "enable_custom_domain",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"custom_ip": {
"name": "custom_ip",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"custom_domain_id": {
"name": "custom_domain_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"custom_domain": {
"name": "custom_domain",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.setting": {
"name": "setting",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"qr_background": {
"name": "qr_background",
"type": "varchar(7)",
"primaryKey": false,
"notNull": false
},
"qr_foreground": {
"name": "qr_foreground",
"type": "varchar(7)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.shortener": {
"name": "shortener",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"link": {
"name": "link",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"ios": {
"name": "ios",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"ios_link": {
"name": "ios_link",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"android": {
"name": "android",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"android_link": {
"name": "android_link",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"code": {
"name": "code",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"project_id": {
"name": "project_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"shortener_code_unique": {
"name": "shortener_code_unique",
"nullsNotDistinct": false,
"columns": [
"code"
]
}
}
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"uuid": {
"name": "uuid",
"type": "uuid",
"primaryKey": false,
"notNull": false,
"default": "gen_random_uuid()"
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"google_id": {
"name": "google_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
}
},
"public.visitor": {
"name": "visitor",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"shortener_id": {
"name": "shortener_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"country_code": {
"name": "country_code",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"country": {
"name": "country",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"city": {
"name": "city",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"device_type": {
"name": "device_type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"device_vendor": {
"name": "device_vendor",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"os": {
"name": "os",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"browser": {
"name": "browser",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "''"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

@ -113,6 +113,13 @@
"when": 1723360689804, "when": 1723360689804,
"tag": "0015_tidy_electro", "tag": "0015_tidy_electro",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1724250058955,
"tag": "0016_steady_darkstar",
"breakpoints": true
} }
] ]
} }

@ -43,6 +43,7 @@
"@prgm/sveltekit-progress-bar": "^2.0.0", "@prgm/sveltekit-progress-bar": "^2.0.0",
"@types/he": "^1.2.3", "@types/he": "^1.2.3",
"apexcharts": "^3.44.0", "apexcharts": "^3.44.0",
"arctic": "^1.9.2",
"bits-ui": "^0.21.13", "bits-ui": "^0.21.13",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cmdk-sv": "^0.0.13", "cmdk-sv": "^0.0.13",

@ -0,0 +1,11 @@
<script lang="ts">
import type { SVGAttributes } from 'svelte/elements'
type $$Props = SVGAttributes<SVGElement>
</script>
<svg role="img" viewBox="0 0 24 24" {...$$restProps}>
<path
fill="currentColor"
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
</svg>

@ -14,8 +14,9 @@ export const user = pgTable('user', {
uuid: uuid('uuid').defaultRandom(), uuid: uuid('uuid').defaultRandom(),
email_verified: boolean('email_verified').notNull().default(false), email_verified: boolean('email_verified').notNull().default(false),
email: varchar('email', { length: 255 }).notNull().unique(), email: varchar('email', { length: 255 }).notNull().unique(),
googleId: varchar('google_id', { length: 255 }),
username: varchar('username', { length: 255 }), username: varchar('username', { length: 255 }),
password: varchar('password', { length: 255 }).notNull(), password: varchar('password', { length: 255 }),
createdAt: timestamp('created_at', { mode: 'string' }) createdAt: timestamp('created_at', { mode: 'string' })
.defaultNow() .defaultNow()
.notNull(), .notNull(),

@ -3,6 +3,8 @@ import { db } from '$lib/db'
import { session, user } from '$lib/db/schema' import { session, user } from '$lib/db/schema'
import { type User } from '$lib/db/types' import { type User } from '$lib/db/types'
import { Lucia } from 'lucia' import { Lucia } from 'lucia'
import { Google } from 'arctic'
import { env } from '$env/dynamic/private'
declare module 'lucia' { declare module 'lucia' {
interface Register { interface Register {
@ -23,3 +25,10 @@ export const lucia = new Lucia(adapter, {
} }
}, },
}) })
export const google = new Google(
env.PRIVATE_GOOGLE_CLIENT_ID,
env.PRIVATE_GOOGLE_CLIENT_SECRET,
(env.APP_ENV === 'prod' ? env.ORIGIN : 'http://localhost:5173') +
'/login/google/callback',
)

@ -56,6 +56,6 @@
{#if $submitting} {#if $submitting}
<LoaderCircle class="animate-spin" /> <LoaderCircle class="animate-spin" />
{/if} {/if}
Login Login with Email
</Form.Button> </Form.Button>
</form> </form>

@ -31,6 +31,18 @@ export const actions: Actions = {
const user = users[0] const user = users[0]
if (user.googleId) {
return setError(
form,
'email',
'This email is detected on Google login, please login via Google',
)
}
if (!user.password) {
return setError(form, 'email', 'Invalid credentials')
}
const matchPassword = const matchPassword =
user && user &&
(await Bun.password.verify(form.data.password, user.password)) (await Bun.password.verify(form.data.password, user.password))

@ -3,39 +3,70 @@
import Form from './(components)/form.svelte' import Form from './(components)/form.svelte'
import type { PageData } from './$types' import type { PageData } from './$types'
import { Button } from '$lib/components/ui/button'
import { LoaderIcon } from 'lucide-svelte'
import Google from '$lib/components/icons/google.svelte'
import { goto } from '$app/navigation'
export let data: PageData export let data: PageData
let isLoading = false
const loginGoogle = async () => {
// isLoading = true
// await fetch('/login/google')
// isLoading = false
window.location.href = '/login/google'
}
</script> </script>
<div <div
class="container relative flex-col justify-center items-center h-screen md:grid lg:grid-cols-2 lg:px-0 lg:max-w-none"> class="container relative h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="absolute top-4 right-4 md:top-8 md:right-8"> <div class="absolute right-4 top-4 md:right-8 md:top-8">
<ThemeToggle /> <ThemeToggle />
</div> </div>
<div <div
class="hidden relative flex-col p-10 h-full text-white lg:flex dark:border-r bg-primary-foreground"> class="bg-primary-foreground relative hidden h-full flex-col p-10 text-white dark:border-r lg:flex">
<div <div
class="flex relative z-20 items-center text-lg font-medium text-primary"> class="text-primary relative z-20 flex items-center text-lg font-medium">
Shortener Shortener
</div> </div>
</div> </div>
<div class="p-8"> <div class="p-8">
<div <div
class="flex flex-col justify-center mx-auto space-y-6 w-full sm:w-[350px]"> class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div class="flex flex-col space-y-2 text-center"> <div class="flex flex-col space-y-2 text-center">
<h1 class="text-2xl font-semibold tracking-tight"> <h1 class="text-2xl font-semibold tracking-tight">
Login to your account Login to your account
</h1> </h1>
<p class="text-sm text-muted-foreground"> <p class="text-muted-foreground text-sm">
Enter your email below to login to your account Enter your email below to login to your account
</p> </p>
</div> </div>
<Button variant="outline" on:click={loginGoogle}>
{#if isLoading}
<LoaderIcon class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Google class="mr-2 h-4 w-4" />
{/if}
Login with Google
</Button>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t" />
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
<Form data={data.form} /> <Form data={data.form} />
<p class="px-8 text-sm text-center text-muted-foreground"> <p class="text-muted-foreground px-8 text-center text-sm">
Don't Have An Account? Signup{' '} Don't Have An Account? Signup{' '}
<a <a
href="/signup" href="/signup"
class="underline underline-offset-4 hover:text-primary"> class="hover:text-primary underline underline-offset-4">
Here Here
</a> </a>
</p> </p>

@ -0,0 +1,36 @@
import { google } from '$lib/server/auth'
import { generateState, generateCodeVerifier } from 'arctic'
import { serializeCookie } from 'oslo/cookie'
import { env } from '$env/dynamic/private'
import { redirect } from '@sveltejs/kit'
export const GET = async (event) => {
const state = generateState()
const codeVerifier = generateCodeVerifier()
const url = await google.createAuthorizationURL(
state,
codeVerifier,
{
scopes: ['email', 'profile'],
},
)
event.cookies.set('google_oauth_state', state, {
httpOnly: true,
secure: env.APP_ENV === 'prod',
maxAge: 60 * 10, // 10 minutes
path: '/',
sameSite: 'lax',
})
event.cookies.set('google_oauth_code_verifier', codeVerifier, {
httpOnly: true,
secure: env.APP_ENV === 'prod',
maxAge: 60 * 10, // 10 minutes
path: '/',
sameSite: 'lax',
})
return redirect(302, url.toString())
}

@ -0,0 +1,127 @@
import { OAuth2RequestError } from 'arctic'
import { google, lucia } from '$lib/server/auth'
import { db } from '$lib/db'
import { user } from '$lib/db/schema'
import { eq } from 'drizzle-orm'
interface GoogleUser {
sub: string // Unique identifier for the user
name: string // Full name of the user
email: string // Email address of the user
}
export async function GET(event) {
const code = event.url.searchParams.get('code')
const state = event.url.searchParams.get('state')
const codeVerifier = event.cookies.get('google_oauth_code_verifier')
const storedState = event.cookies.get('google_oauth_state') ?? null
if (
!code ||
!state ||
!storedState ||
!codeVerifier ||
state !== storedState
) {
return new Response(null, {
status: 400,
})
}
try {
const tokens = await google.validateAuthorizationCode(
code,
codeVerifier,
)
const googleUserResponse = await fetch(
'https://www.googleapis.com/oauth2/v3/userinfo',
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
},
)
const googleUser: GoogleUser = await googleUserResponse.json()
const existingGoogleUser = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.googleId, googleUser.sub),
})
if (existingGoogleUser) {
const session = await lucia.createSession(
existingGoogleUser.id,
{},
)
const sessionCookie = lucia.createSessionCookie(session.id)
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes,
})
return new Response(null, {
status: 302,
headers: {
Location: '/dashboard',
},
})
}
const existingUser = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.email, googleUser.email),
})
if (existingUser) {
const updateUser = await db
.update(user)
.set({
email_verified: true,
password: null,
username: googleUser.name,
})
.where(eq(user.id, existingUser.id))
.returning()
const newUser = updateUser[0]
const session = await lucia.createSession(newUser.id, {})
const sessionCookie = lucia.createSessionCookie(session.id)
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes,
})
} else {
const insertUser = await db
.insert(user)
.values({
email: googleUser.email, // Using email as username
email_verified: true,
googleId: googleUser.sub,
username: googleUser.name, // Name field may not always be present, handle accordingly
})
.returning()
const newUser = insertUser[0]
const session = await lucia.createSession(newUser.id, {})
const sessionCookie = lucia.createSessionCookie(session.id)
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes,
})
}
return new Response(null, {
status: 302,
headers: {
Location: '/dashboard',
},
})
} catch (e) {
return new Response(null, {
status: 302,
headers: {
Location: '/login',
},
})
}
}

@ -37,6 +37,13 @@ export const actions: Actions = {
const user = users[0] const user = users[0]
if (user) { if (user) {
if (user.googleId) {
return setError(
form,
'email',
'This email is detected on Google login, please login via Google',
)
}
return setError(form, 'email', 'Email Already Exist') return setError(form, 'email', 'Email Already Exist')
} }

Loading…
Cancel
Save