Setup login flow and some style changes
This commit is contained in:
parent
fcc59f0afb
commit
608c23d271
10 changed files with 361 additions and 35 deletions
14
src/app.d.ts
vendored
14
src/app.d.ts
vendored
|
|
@ -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
155
src/database.types.ts
Normal 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
54
src/hooks.server.ts
Normal 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'
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
26
src/routes/auth/confirm/+server.ts
Normal file
26
src/routes/auth/confirm/+server.ts
Normal 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");
|
||||
}
|
||||
15
src/routes/dashboard/+page.server.ts
Normal file
15
src/routes/dashboard/+page.server.ts
Normal 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: ''
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 we’ll 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 we’ll send you a magic link.
|
||||
</p>
|
||||
|
||||
<form class="login-form" method = "POST">
|
||||
<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>
|
||||
</form>
|
||||
{#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" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Sending…" : "Send magic link"}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
8
src/routes/logout/+server.ts
Normal file
8
src/routes/logout/+server.ts
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue