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
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// 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 {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
interface Locals {
|
||||||
// interface PageData {}
|
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 PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
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;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-nav form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.site-nav .login-link {
|
.site-nav .login-link {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--color-text);
|
border: 1px solid var(--color-text);
|
||||||
color: 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);
|
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;
|
background: transparent;
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
|
outline: 0;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,12 +206,12 @@ textarea#text-input:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-page {
|
.login-page {
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: calc(100vh - 96px);
|
min-height: calc(100vh - 96px);
|
||||||
padding-bottom: 48px;
|
padding-bottom: 24px;
|
||||||
padding-top: 48px;
|
padding-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
|
|
@ -265,6 +280,14 @@ textarea#text-input:focus {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
button,
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
input[type="button"] {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--color-text);
|
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">
|
<header class="site-header">
|
||||||
<a class="site-logo" href="/">gitKeep</a>
|
<a class="site-logo" href="/">gitKeep</a>
|
||||||
<nav class="site-nav" aria-label="Main navigation">
|
<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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,37 @@
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from "@sveltejs/kit";
|
||||||
import { supabase } from '$lib/server/supabase.ts';
|
import type { Actions } from "./$types";
|
||||||
import type { Actions } from './$types';
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({request, url}) =>{
|
default: async ({ request, url, locals }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const email = String(form.get("email") ?? "");
|
const email = String(form.get("email") ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
const baseUrl = url.origin;
|
const baseUrl = url.origin;
|
||||||
|
|
||||||
if (!email){
|
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: {
|
options: {
|
||||||
shouldCreateUser: true,
|
shouldCreateUser: true,
|
||||||
emailRedirectTo: `${baseUrl}/dashboard`,
|
emailRedirectTo: `${baseUrl}/auth/confirm`,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (error){
|
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 };
|
||||||
|
},
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,46 @@
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a class="site-logo" href="/">gitKeep</a>
|
<a class="site-logo" href="/">gitKeep</a>
|
||||||
<nav class="site-nav" aria-label="Main navigation">
|
|
||||||
<a href="/">Home</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from "./$types";
|
||||||
|
|
||||||
|
let { form }: PageProps = $props();
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
<section class="container login-page">
|
<section class="container login-page">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<h1 class="display-heading">Login</h1>
|
<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">
|
{#if form?.message}
|
||||||
<label for="email">Email</label>
|
<p class="error-message">{form.message}</p>
|
||||||
<input id="email" name="email" type="email" autocomplete="email" placeholder="you@example.com" required />
|
{/if}
|
||||||
<button type="submit">Send magic link</button>
|
|
||||||
</form>
|
<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>
|
</div>
|
||||||
</section>
|
</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