diff --git a/frontend/.env.example b/frontend/.env.example index 7aa65a1..e8a373f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -24,3 +24,7 @@ PRIVATE_FLYIO_IPV6= # Resend config (if you have a Resend hosting provider) PRIVATE_RESEND_API_KEY= + +# Google OAuth +PRIVATE_GOOGLE_CLIENT_ID= +PRIVATE_GOOGLE_CLIENT_SECRET= \ No newline at end of file diff --git a/frontend/bun.lockb b/frontend/bun.lockb index d4a3e76..d9aea3b 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/drizzle/0016_steady_darkstar.sql b/frontend/drizzle/0016_steady_darkstar.sql new file mode 100644 index 0000000..c10b4cc --- /dev/null +++ b/frontend/drizzle/0016_steady_darkstar.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ALTER COLUMN "password" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "google_id" varchar(255); \ No newline at end of file diff --git a/frontend/drizzle/meta/0016_snapshot.json b/frontend/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..f72f870 --- /dev/null +++ b/frontend/drizzle/meta/0016_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json index e0f9ba1..a853288 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1723360689804, "tag": "0015_tidy_electro", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1724250058955, + "tag": "0016_steady_darkstar", + "breakpoints": true } ] } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 09e79ea..89aeda9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,68 +1,69 @@ { - "name": "link-shortener-svelte", - "version": "0.0.1", - "private": true, - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --plugin-search-dir . --check .", - "format": "prettier --write .", - "migrate": "bun ./src/lib/db/migrate.ts", - "db:push": "drizzle-kit push", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate" - }, - "devDependencies": { - "@sveltejs/adapter-node": "^2.0.0", - "@sveltejs/kit": "^2.5.5", - "@sveltejs/vite-plugin-svelte": "^3.0.2", - "@types/pg": "^8.11.6", - "autoprefixer": "^10.4.14", - "bun-types": "^1.0.11", - "drizzle-kit": "^0.23.1", - "lucia": "^3.1.1", - "postcss": "^8.4.24", - "postcss-load-config": "^4.0.1", - "prettier": "^3.1.0", - "prettier-plugin-svelte": "^3.1.0", - "prettier-plugin-tailwindcss": "^0.5.7", - "svelte": "^4.2.17", - "svelte-adapter-bun": "^0.5.1", - "svelte-check": "^3.4.3", - "tailwindcss": "^3.3.2", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^5.2.8" - }, - "type": "module", - "dependencies": { - "@lucia-auth/adapter-drizzle": "^1.0.7", - "@prgm/sveltekit-progress-bar": "^2.0.0", - "@types/he": "^1.2.3", - "apexcharts": "^3.44.0", - "bits-ui": "^0.21.13", - "clsx": "^2.0.0", - "cmdk-sv": "^0.0.13", - "drizzle-orm": "^0.32.1", - "formsnap": "^1.0.0", - "he": "^1.2.0", - "lucide-svelte": "^0.418.0", - "mode-watcher": "^0.1.2", - "nanoid": "^5.0.3", - "node-html-parser": "^6.1.12", - "oslo": "^1.2.0", - "pg": "^8.11.5", - "postgres": "^3.4.3", - "qr-code-styling": "^1.6.0-rc.1", - "resend": "^3.4.0", - "svelte-sonner": "^0.3.10", - "sveltekit-superforms": "^2.12.2", - "tailwind-merge": "^2.0.0", - "tailwind-variants": "^0.1.18", - "vaul-svelte": "^0.3.1", - "zod": "^3.22.4" - } + "name": "link-shortener-svelte", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --plugin-search-dir . --check .", + "format": "prettier --write .", + "migrate": "bun ./src/lib/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^2.0.0", + "@sveltejs/kit": "^2.5.5", + "@sveltejs/vite-plugin-svelte": "^3.0.2", + "@types/pg": "^8.11.6", + "autoprefixer": "^10.4.14", + "bun-types": "^1.0.11", + "drizzle-kit": "^0.23.1", + "lucia": "^3.1.1", + "postcss": "^8.4.24", + "postcss-load-config": "^4.0.1", + "prettier": "^3.1.0", + "prettier-plugin-svelte": "^3.1.0", + "prettier-plugin-tailwindcss": "^0.5.7", + "svelte": "^4.2.17", + "svelte-adapter-bun": "^0.5.1", + "svelte-check": "^3.4.3", + "tailwindcss": "^3.3.2", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^5.2.8" + }, + "type": "module", + "dependencies": { + "@lucia-auth/adapter-drizzle": "^1.0.7", + "@prgm/sveltekit-progress-bar": "^2.0.0", + "@types/he": "^1.2.3", + "apexcharts": "^3.44.0", + "arctic": "^1.9.2", + "bits-ui": "^0.21.13", + "clsx": "^2.0.0", + "cmdk-sv": "^0.0.13", + "drizzle-orm": "^0.32.1", + "formsnap": "^1.0.0", + "he": "^1.2.0", + "lucide-svelte": "^0.418.0", + "mode-watcher": "^0.1.2", + "nanoid": "^5.0.3", + "node-html-parser": "^6.1.12", + "oslo": "^1.2.0", + "pg": "^8.11.5", + "postgres": "^3.4.3", + "qr-code-styling": "^1.6.0-rc.1", + "resend": "^3.4.0", + "svelte-sonner": "^0.3.10", + "sveltekit-superforms": "^2.12.2", + "tailwind-merge": "^2.0.0", + "tailwind-variants": "^0.1.18", + "vaul-svelte": "^0.3.1", + "zod": "^3.22.4" + } } diff --git a/frontend/src/lib/components/icons/google.svelte b/frontend/src/lib/components/icons/google.svelte new file mode 100644 index 0000000..580ae4b --- /dev/null +++ b/frontend/src/lib/components/icons/google.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/frontend/src/lib/db/schema.ts b/frontend/src/lib/db/schema.ts index 6ea307b..880209c 100644 --- a/frontend/src/lib/db/schema.ts +++ b/frontend/src/lib/db/schema.ts @@ -14,8 +14,9 @@ export const user = pgTable('user', { uuid: uuid('uuid').defaultRandom(), email_verified: boolean('email_verified').notNull().default(false), email: varchar('email', { length: 255 }).notNull().unique(), + googleId: varchar('google_id', { length: 255 }), username: varchar('username', { length: 255 }), - password: varchar('password', { length: 255 }).notNull(), + password: varchar('password', { length: 255 }), createdAt: timestamp('created_at', { mode: 'string' }) .defaultNow() .notNull(), diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index ebf30d5..0994823 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -3,6 +3,8 @@ import { db } from '$lib/db' import { session, user } from '$lib/db/schema' import { type User } from '$lib/db/types' import { Lucia } from 'lucia' +import { Google } from 'arctic' +import { env } from '$env/dynamic/private' declare module 'lucia' { 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', +) diff --git a/frontend/src/routes/(auth)/login/(components)/form.svelte b/frontend/src/routes/(auth)/login/(components)/form.svelte index 37235fa..b60adc4 100644 --- a/frontend/src/routes/(auth)/login/(components)/form.svelte +++ b/frontend/src/routes/(auth)/login/(components)/form.svelte @@ -56,6 +56,6 @@ {#if $submitting} {/if} - Login + Login with Email diff --git a/frontend/src/routes/(auth)/login/+page.server.ts b/frontend/src/routes/(auth)/login/+page.server.ts index 60ba347..346b148 100644 --- a/frontend/src/routes/(auth)/login/+page.server.ts +++ b/frontend/src/routes/(auth)/login/+page.server.ts @@ -31,6 +31,18 @@ export const actions: Actions = { 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 = user && (await Bun.password.verify(form.data.password, user.password)) diff --git a/frontend/src/routes/(auth)/login/+page.svelte b/frontend/src/routes/(auth)/login/+page.svelte index 3018a8a..447f498 100644 --- a/frontend/src/routes/(auth)/login/+page.svelte +++ b/frontend/src/routes/(auth)/login/+page.svelte @@ -3,39 +3,70 @@ import Form from './(components)/form.svelte' 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 + + let isLoading = false + + const loginGoogle = async () => { + // isLoading = true + // await fetch('/login/google') + // isLoading = false + window.location.href = '/login/google' + }
-
+ class="container relative h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> +
+ class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">

Login to your account

-

+

Enter your email below to login to your account

+ +
+
+ +
+
+ + Or continue with + +
+
-

+

Don't Have An Account? Signup{' '} + class="hover:text-primary underline underline-offset-4"> Here

diff --git a/frontend/src/routes/(auth)/login/google/+server.ts b/frontend/src/routes/(auth)/login/google/+server.ts new file mode 100644 index 0000000..2b33d55 --- /dev/null +++ b/frontend/src/routes/(auth)/login/google/+server.ts @@ -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()) +} diff --git a/frontend/src/routes/(auth)/login/google/callback/+server.ts b/frontend/src/routes/(auth)/login/google/callback/+server.ts new file mode 100644 index 0000000..85ff945 --- /dev/null +++ b/frontend/src/routes/(auth)/login/google/callback/+server.ts @@ -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', + }, + }) + } +} diff --git a/frontend/src/routes/(auth)/signup/+page.server.ts b/frontend/src/routes/(auth)/signup/+page.server.ts index 62181ad..4179452 100644 --- a/frontend/src/routes/(auth)/signup/+page.server.ts +++ b/frontend/src/routes/(auth)/signup/+page.server.ts @@ -37,6 +37,13 @@ export const actions: Actions = { const user = users[0] 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') }