Setup login flow and some style changes

This commit is contained in:
Alex Selimov 2026-06-01 07:09:37 -04:00
parent fcc59f0afb
commit 608c23d271
10 changed files with 361 additions and 35 deletions

14
src/app.d.ts vendored
View file

@ -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<typeof SupabaseClient<Database>>
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>
session: Session | null
user: User | null
}
interface PageData {
session: Session | null
}
// interface PageState {}
// interface Platform {}
}
}
export {};

155
src/database.types.ts Normal file
View file

@ -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<Database, { PostgrestVersion: 'XX' }>(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<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
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

54
src/hooks.server.ts Normal file
View file

@ -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<Database>(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'
},
})
}

View file

@ -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);

View file

@ -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");
}

View file

@ -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: ''
};
};

View file

@ -1,7 +1,9 @@
<header class="site-header">
<a class="site-logo" href="/">gitKeep</a>
<nav class="site-nav" aria-label="Main navigation">
<a class="login-link" href="#get-started">Logout</a>
<form method="POST" action="/logout">
<button class="login-link" type="submit">Logout</button>
</form>
</nav>
</header>

View file

@ -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"})
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);
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 };
},
};

View file

@ -1,19 +1,46 @@
<header class="site-header">
<a class="site-logo" href="/">gitKeep</a>
<nav class="site-nav" aria-label="Main navigation">
<a href="/">Home</a>
</nav>
</header>
<script lang="ts">
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let isSubmitting = $state(false);
</script>
<section class="container login-page">
<div class="login-card">
<h1 class="display-heading">Login</h1>
<p class="login-copy">Enter your email and well send you a magic link.</p>
{#if form?.success}
<h4>Check your email for your login link!</h4>
{:else}
<p class="login-copy">
Enter your email and well send you a magic link.
</p>
<form class="login-form" method = "POST">
{#if form?.message}
<p class="error-message">{form.message}</p>
{/if}
<form
class="login-form"
method="POST"
onsubmit={() => (isSubmitting = true)}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" placeholder="you@example.com" required />
<button type="submit">Send magic link</button>
<input
id="email"
name="email"
type="email"
autocomplete="email"
placeholder="you@example.com"
required
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending…" : "Send magic link"}
</button>
</form>
{/if}
</div>
</section>

View file

@ -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");
}