Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/netlify-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
VITE_USE_SUPABASE: true
PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }}
PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
SUPABASE_JWT_SECRET: ${{ secrets.SUPABASE_JWT_SECRET }}

- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v3.0
Expand Down
28 changes: 28 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
"@reown/appkit": "^1.8.14",
"@reown/appkit-adapter-wagmi": "^1.8.14",
"@supabase/supabase-js": "^2.84.0",
"@types/jsonwebtoken": "^9.0.10",
"@wagmi/core": "^2.22.1",
"jsonwebtoken": "^9.0.3",
"lucide-svelte": "^0.554.0",
"qrcode": "^1.5.4",
"viem": "^2.39.0",
Expand Down
13 changes: 11 additions & 2 deletions src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
user: {
sub: string;
user_metadata: {
address: string;
};
iat: number;
exp: number;
} | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}

export {};
export { };
21 changes: 21 additions & 0 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { SUPABASE_JWT_SECRET } from '$env/static/private';
import type { Handle } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';

export const handle: Handle = async ({ event, resolve }) => {
const authHeader = event.request.headers.get('authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;

if (token) {
try {
const decodedPayload = jwt.verify(token, SUPABASE_JWT_SECRET, { algorithms: ['HS256'] }) as any;
event.locals.user = decodedPayload;
} catch (error) {
event.locals.user = null;
}
} else {
event.locals.user = null;
}

return resolve(event);
};
55 changes: 55 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getWalletClient } from "@wagmi/core";
import type { Address } from "viem";

const AUTH_TOKEN_KEY = 'auth_token';

export async function signInWithWallet(address: Address, config: any): Promise<string> {
try {
const timestamp = Date.now();
const message = `Sign in to Gnosis Split\n\nTimestamp: ${timestamp}`;

const walletClient = await getWalletClient(config, { account: address });
if (!walletClient) {
throw new Error('Failed to get wallet client');
}

const signature = await walletClient.signMessage({
message,
account: address
});

const response = await fetch('/api/auth/signin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
signature,
address,
timestamp
})
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Sign-in failed');
}

const { token } = await response.json();

localStorage.setItem(AUTH_TOKEN_KEY, token);

return token;
} catch (error) {
throw error;
}
}

export function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(AUTH_TOKEN_KEY);
}

export function clearAuth(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(AUTH_TOKEN_KEY);
}
36 changes: 30 additions & 6 deletions src/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
import { browser } from '$app/environment';
import type { Split, Participant } from './types';
import { getAuthToken } from './auth';

const USE_SUPABASE = import.meta.env.VITE_USE_SUPABASE === 'true';
const STORAGE_KEY = 'gnosisSplits';
const API_BASE = '/api/splits';

export function getAuthHeaders(): Record<string, string> {
const token = getAuthToken();
if (!token) {
return {};
}
return {
'Authorization': `Bearer ${token}`
};
}

export function getHeaders(additionalHeaders?: Record<string, string>): Record<string, string> {
return {
'Content-Type': 'application/json',
...getAuthHeaders(),
...additionalHeaders
};
}

export async function getSplits(userAddress?: string): Promise<Split[]> {
if (!USE_SUPABASE) {
if (!browser) return [];
Expand All @@ -23,7 +42,9 @@ export async function getSplits(userAddress?: string): Promise<Split[]> {
} else {
try {
const url = userAddress ? `${API_BASE}?address=${encodeURIComponent(userAddress)}` : API_BASE;
const response = await fetch(url);
const response = await fetch(url, {
headers: getHeaders()
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('API error:', response.status, errorData);
Expand Down Expand Up @@ -55,7 +76,7 @@ export async function saveSplit(split: Omit<Split, 'id' | 'createdAt'>): Promise

const response = await fetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: getHeaders(),
body: JSON.stringify(split)
});

Expand All @@ -71,7 +92,9 @@ export async function getSplit(id: string, userAddress?: string): Promise<Split

try {
const url = userAddress ? `${API_BASE}/${id}?address=${encodeURIComponent(userAddress)}` : `${API_BASE}/${id}`;
const response = await fetch(url);
const response = await fetch(url, {
headers: getHeaders()
});
if (!response.ok) return undefined;
return await response.json();
} catch (error) {
Expand Down Expand Up @@ -104,7 +127,7 @@ export async function updateSplit(id: string, updater: (split: Split) => Split,
const url = userAddress ? `${API_BASE}/${id}?address=${encodeURIComponent(userAddress)}` : `${API_BASE}/${id}`;
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: getHeaders(),
body: JSON.stringify(updated)
});

Expand All @@ -126,7 +149,8 @@ export async function deleteSplit(id: string): Promise<void> {
}
} else {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE'
method: 'DELETE',
headers: getHeaders()
});

if (!response.ok) throw new Error('Failed to delete split');
Expand Down Expand Up @@ -154,7 +178,7 @@ export async function updateSplitParticipants(splitId: string, participants: Par

const response = await fetch(`${API_BASE}/${splitId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: getHeaders(),
body: JSON.stringify({ participants })
});

Expand Down
64 changes: 64 additions & 0 deletions src/lib/stores/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { writable, get } from 'svelte/store';
import { address } from './wallet';
import { signInWithWallet, getAuthToken, clearAuth } from '$lib/auth';
import { config } from '$lib/appkit';
import type { Address } from 'viem';

let lastAddress: Address | string | null = null;

export const isAuthenticated = writable(false);
export const isSigningIn = writable(false);
export const signInError = writable<string | null>(null);

export async function attemptSignIn(walletAddress: Address | string) {
try {
isSigningIn.set(true);
signInError.set(null);

if (!config) {
throw new Error('Wagmi config not initialized. Please refresh the page.');
}

await signInWithWallet(walletAddress as Address, config);

isAuthenticated.set(true);
} catch (error) {
const message = error instanceof Error ? error.message : 'Sign-in failed';
signInError.set(message);
} finally {
isSigningIn.set(false);
}
}

export function checkAuthStatus() {
const token = getAuthToken();
isAuthenticated.set(!!token);
}

if (typeof window !== 'undefined') {
checkAuthStatus();
}

address.subscribe(async ($address) => {
if (lastAddress && lastAddress !== $address) {
clearAuth();
isAuthenticated.set(false);
signInError.set(null);
}

if (!$address) {
clearAuth();
isAuthenticated.set(false);
signInError.set(null);
lastAddress = null;
return;
}

lastAddress = $address;

const currentlyAuthenticated = get(isAuthenticated);

if (!currentlyAuthenticated) {
await attemptSignIn($address);
}
});
3 changes: 2 additions & 1 deletion src/lib/supabase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { writable } from 'svelte/store';
import type { Split } from './types';
import { getHeaders } from './storage';

export const createSplitStore = (splitId: string, userAddress?: string) => {
const { subscribe, set } = writable<Split | null>(null);
Expand All @@ -11,7 +12,7 @@ export const createSplitStore = (splitId: string, userAddress?: string) => {
const load = async () => {
try {
const url = userAddress ? `/api/splits/${splitId}?address=${encodeURIComponent(userAddress)}` : `/api/splits/${splitId}`;
const response = await fetch(url);
const response = await fetch(url, { headers: getHeaders() });
if (!response.ok) {
console.error('Failed to load split');
return;
Expand Down
1 change: 1 addition & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import favicon from '$lib/assets/favicon.svg';
import BottomNav from '$lib/components/BottomNav.svelte';
import '$lib/appkit';
import '$lib/stores/auth';
import { Toaster } from '$lib/components/ui/sonner';
import Github from 'lucide-svelte/icons/github';

Expand Down
Loading