From 608c23d2711b017075f7aa3987b7c25bf3785595 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Mon, 1 Jun 2026 07:09:37 -0400 Subject: [PATCH] Setup login flow and some style changes --- src/app.d.ts | 14 ++- src/database.types.ts | 155 +++++++++++++++++++++++++++ src/hooks.server.ts | 54 ++++++++++ src/lib/styles/style.css | 31 +++++- src/routes/auth/confirm/+server.ts | 26 +++++ src/routes/dashboard/+page.server.ts | 15 +++ src/routes/dashboard/+page.svelte | 4 +- src/routes/login/+page.server.ts | 44 ++++---- src/routes/login/+page.svelte | 45 ++++++-- src/routes/logout/+server.ts | 8 ++ 10 files changed, 361 insertions(+), 35 deletions(-) create mode 100644 src/database.types.ts create mode 100644 src/hooks.server.ts create mode 100644 src/routes/auth/confirm/+server.ts create mode 100644 src/routes/dashboard/+page.server.ts create mode 100644 src/routes/logout/+server.ts diff --git a/src/app.d.ts b/src/app.d.ts index 520c421..5bcc9f0 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,13 +1,21 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import type { Session, SupabaseClient, User } from '@supabase/supabase-js' +import type { Database } from './database.types.ts' // import generated types declare global { namespace App { // interface Error {} - // interface Locals {} - // interface PageData {} + interface Locals { + supabase: ReturnType> + safeGetSession: () => Promise<{ session: Session | null; user: User | null }> + session: Session | null + user: User | null + } + interface PageData { + session: Session | null + } // interface PageState {} // interface Platform {} } } - export {}; diff --git a/src/database.types.ts b/src/database.types.ts new file mode 100644 index 0000000..ba7e5bb --- /dev/null +++ b/src/database.types.ts @@ -0,0 +1,155 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.5" + } + public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: {}, + }, +} as const diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..cad3853 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,54 @@ +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public' +import { createServerClient } from '@supabase/ssr' +import type { Handle } from '@sveltejs/kit' +import type {Database} from "./database.types.ts" +export const handle: Handle = ({ event, resolve }) => { + event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, { + cookies: { + getAll() { + return event.cookies.getAll() + }, + setAll(cookiesToSet, headers) { + /** + * Note: You have to add the `path` variable to the + * set and remove method due to sveltekit's cookie API + * requiring this to be set, setting the path to an empty string + * will replicate previous/standard behavior (https://kit.svelte.dev/docs/types#public-types-cookies) + */ + cookiesToSet.forEach(({ name, value, options }) => + event.cookies.set(name, value, { ...options, path: '/' }) + ) + if (Object.keys(headers).length > 0) { + event.setHeaders(headers) + } + }, + }, + }) + /** + * Unlike `supabase.auth.getSession()`, which returns the session _without_ + * validating the JWT, this function also calls `getUser()` to validate the + * JWT before returning the session. + */ + event.locals.safeGetSession = async () => { + const { + data: { session }, + } = await event.locals.supabase.auth.getSession() + if (!session) { + return { session: null, user: null } + } + const { + data: { user }, + error, + } = await event.locals.supabase.auth.getUser() + if (error) { + // JWT validation has failed + return { session: null, user: null } + } + return { session, user } + } + return resolve(event, { + filterSerializedResponseHeaders(name) { + return name === 'content-range' || name === 'x-supabase-api-version' + }, + }) +} diff --git a/src/lib/styles/style.css b/src/lib/styles/style.css index 6c1ba7c..cb80814 100644 --- a/src/lib/styles/style.css +++ b/src/lib/styles/style.css @@ -86,17 +86,32 @@ h6 { text-decoration: underline; } +.site-nav form { + margin: 0; +} + .site-nav .login-link { background: transparent; border: 1px solid var(--color-text); color: var(--color-text); + cursor: pointer; + font-family: 'VT323', monospace; + font-size: clamp(16px, 5vw, 24px); + font-weight: 400; + height: auto; + line-height: 1; + margin: 0; padding: clamp(2px, 1vw, 4px) clamp(8px, 2.5vw, 14px); + text-transform: none; + white-space: nowrap; } -.site-nav .login-link:hover { +.site-nav .login-link:hover, +.site-nav .login-link:focus { background: transparent; border-color: var(--color-accent); color: var(--color-accent); + outline: 0; text-decoration: underline; } @@ -191,12 +206,12 @@ textarea#text-input:focus { } .login-page { - align-items: center; + align-items: flex-start; display: flex; justify-content: center; min-height: calc(100vh - 96px); - padding-bottom: 48px; - padding-top: 48px; + padding-bottom: 24px; + padding-top: 24px; } .login-card { @@ -265,6 +280,14 @@ textarea#text-input:focus { width: 220px; } +.button, +button, +input[type="submit"], +input[type="reset"], +input[type="button"] { + border-radius: 0; +} + button { background: transparent; border: 1px solid var(--color-text); diff --git a/src/routes/auth/confirm/+server.ts b/src/routes/auth/confirm/+server.ts new file mode 100644 index 0000000..dcba822 --- /dev/null +++ b/src/routes/auth/confirm/+server.ts @@ -0,0 +1,26 @@ + import { redirect } from '@sveltejs/kit'; + import type { RequestHandler } from './$types'; + import type { EmailOtpType } from '@supabase/supabase-js'; + + export const GET: RequestHandler = async ({url, locals}) => { + const token_hash = url.searchParams.get('token_hash'); + const type = url.searchParams.get('type') as EmailOtpType | null; + + if (!token_hash) { + throw redirect(303, "/login") + }else if (!type){ + throw redirect(303, "/login") + } + + const { error } = await locals.supabase.auth.verifyOtp({ + token_hash, + type + }); + + if (error){ + throw redirect(303, "/login"); + } + + + throw redirect(303, "/dashboard"); + } diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts new file mode 100644 index 0000000..1ac2cd0 --- /dev/null +++ b/src/routes/dashboard/+page.server.ts @@ -0,0 +1,15 @@ +import {redirect} from "@sveltejs/kit" +import type { PageServerLoad } from "./$types" + +export const load: PageServerLoad = async ({locals}) => { + const { session, user} = await locals.safeGetSession(); + + if (!session || !user){ + throw redirect(303, "/login"); + } + return { + session, + user, + markdown: '' + }; +}; diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index 72e799f..c3ba2c3 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -1,7 +1,9 @@ diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index a6c4bb3..fd17e4a 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -1,29 +1,37 @@ - import { fail } from '@sveltejs/kit'; - import { supabase } from '$lib/server/supabase.ts'; - import type { Actions } from './$types'; +import { fail } from "@sveltejs/kit"; +import type { Actions } from "./$types"; export const actions: Actions = { - default: async ({request, url}) =>{ + default: async ({ request, url, locals }) => { const form = await request.formData(); - const email = String(form.get("email") ?? ""); + const email = String(form.get("email") ?? "") + .trim() + .toLowerCase(); const baseUrl = url.origin; - if (!email){ - return fail(400, {message: "Missing email"}) + if (!email) { + return fail(400, { message: "Missing email" }); } - const { error } = await supabase.auth.signInWithOtp({ - email: email, + + const { error } = await locals.supabase.auth.signInWithOtp({ + email, options: { shouldCreateUser: true, - emailRedirectTo: `${baseUrl}/dashboard`, + emailRedirectTo: `${baseUrl}/auth/confirm`, }, - }) - - if (error){ - return fail(400, error.message); + }); + + if (error) { + const isRateLimited = error.status === 429 || + error.message.toLowerCase().includes("rate limit"); + + return fail(isRateLimited ? 429 : 400, { + message: isRateLimited + ? "Too many login emails requested. Please wait a minute, then try again." + : error.message, + }); } - return {success: true} - - } -} + return { success: true, email }; + }, +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index d9eb671..92c0c64 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,19 +1,46 @@ + + diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts new file mode 100644 index 0000000..317c626 --- /dev/null +++ b/src/routes/logout/+server.ts @@ -0,0 +1,8 @@ +import {redirect} from "@sveltejs/kit" +import type { RequestHandler } from ".$types" + +export const POST: RequestHandler = async ({locals}) => { + await locals.supabase.auth.signOut(); + + throw redirect(303, "/login"); +}