init
This commit is contained in:
10
src/lib/events/emitter.ts
Normal file
10
src/lib/events/emitter.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
export const serverEvents = new EventEmitter();
|
||||
|
||||
export type TestEvent = {
|
||||
type: "test";
|
||||
playerId: string;
|
||||
};
|
||||
|
||||
export type ServerEvent = TestEvent;
|
||||
199
src/lib/logger/index.ts
Normal file
199
src/lib/logger/index.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
type LogLevel = 'info' | 'success' | 'warn' | 'error';
|
||||
|
||||
interface LoggerOptions {
|
||||
enabled?: boolean;
|
||||
showTimestamp?: boolean;
|
||||
collapsed?: boolean;
|
||||
colors?: boolean;
|
||||
}
|
||||
|
||||
// Cache for performance - update once per second max
|
||||
let cachedTimestamp = '';
|
||||
let lastTimestampUpdate = 0;
|
||||
|
||||
/**
|
||||
* Get formatted timestamp with caching for performance
|
||||
* Format: MM/DD HH:mm:ss
|
||||
*/
|
||||
function getTimestamp(): string {
|
||||
const now = Date.now();
|
||||
|
||||
// Update cache only if more than 1 second has passed
|
||||
if (now - lastTimestampUpdate > 1000) {
|
||||
const date = new Date(now);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
cachedTimestamp = `${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
lastTimestampUpdate = now;
|
||||
}
|
||||
|
||||
return cachedTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color and emoji for each log level
|
||||
*/
|
||||
function getLevelStyle(level: LogLevel): { color: string; label: string } {
|
||||
const styles = {
|
||||
info: { color: '#f5f5f5', label: 'INFO' },
|
||||
success: { color: '#10B981', label: 'SUCCESS' },
|
||||
warn: { color: '#F59E0B', label: 'WARN' },
|
||||
error: { color: '#EF4444', label: 'ERROR' },
|
||||
};
|
||||
|
||||
return styles[level] || styles.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main logger class
|
||||
*/
|
||||
class Logger {
|
||||
private options: LoggerOptions;
|
||||
private context?: string;
|
||||
|
||||
constructor(context?: string, options: LoggerOptions = {}) {
|
||||
this.context = context;
|
||||
this.options = {
|
||||
enabled: process.env.NODE_ENV !== 'production',
|
||||
showTimestamp: true,
|
||||
collapsed: true,
|
||||
colors: true,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with a specific context
|
||||
*/
|
||||
child(context: string, options?: LoggerOptions): Logger {
|
||||
const childContext = this.context ? `${this.context} > ${context}` : context;
|
||||
return new Logger(childContext, { ...this.options, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Core logging method
|
||||
*/
|
||||
private log(
|
||||
level: LogLevel,
|
||||
label: string,
|
||||
data?: any,
|
||||
...rest: any[]
|
||||
): void {
|
||||
if (!this.options.enabled) return;
|
||||
|
||||
const style = getLevelStyle(level);
|
||||
const timestamp = this.options.showTimestamp ? `${getTimestamp()} │ ` : '';
|
||||
const context = this.context ? ` │ ${this.context}` : '';
|
||||
|
||||
const groupLabel = `${timestamp}${style.label}${context} │ ${label}`;
|
||||
|
||||
const group = this.options.collapsed ? console.groupCollapsed : console.group;
|
||||
|
||||
if (this.options.colors && typeof window !== 'undefined') {
|
||||
group(
|
||||
`%c${groupLabel}`,
|
||||
`color: ${style.color}; font-weight: bold;`
|
||||
);
|
||||
} else {
|
||||
group(groupLabel);
|
||||
}
|
||||
|
||||
if (data !== undefined) {
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
if (rest.length > 0) {
|
||||
for (const item of rest) {
|
||||
console.log(item);
|
||||
}
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* log level methods
|
||||
*/
|
||||
info(label: string, data?: any, ...rest: any[]): void {
|
||||
this.log('info', label, data, ...rest);
|
||||
}
|
||||
|
||||
success(label: string, data?: any, ...rest: any[]): void {
|
||||
this.log('success', label, data, ...rest);
|
||||
}
|
||||
|
||||
warn(label: string, data?: any, ...rest: any[]): void {
|
||||
this.log('warn', label, data, ...rest);
|
||||
}
|
||||
|
||||
error(label: string, data?: any, ...rest: any[]): void {
|
||||
this.log('error', label, data, ...rest);
|
||||
}
|
||||
|
||||
simple(message: string): void {
|
||||
if (!this.options.enabled) return;
|
||||
|
||||
const style = getLevelStyle('info');
|
||||
const timestamp = this.options.showTimestamp ? `${getTimestamp()} │ ` : '';
|
||||
const context = this.context ? ` │ ${this.context}` : '';
|
||||
|
||||
const logMessage = `${timestamp}${style.label}${context} │ ${message}`;
|
||||
|
||||
if (this.options.colors && typeof window !== 'undefined') {
|
||||
console.log(`%c${logMessage}`, `color: ${style.color};`);
|
||||
} else {
|
||||
console.log(logMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure performance of an operation
|
||||
*/
|
||||
async measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
const duration = (performance.now() - start).toFixed(2);
|
||||
|
||||
this.success(`${label} completed`, {
|
||||
duration: `${duration}ms`,
|
||||
result
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = (performance.now() - start).toFixed(2);
|
||||
|
||||
this.error(`${label} failed`, {
|
||||
duration: `${duration}ms`,
|
||||
error
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a table log
|
||||
*/
|
||||
table(label: string, data: any[]): void {
|
||||
if (!this.options.enabled) return;
|
||||
|
||||
const timestamp = this.options.showTimestamp ? `${getTimestamp()} │ ` : '';
|
||||
const context = this.context ? ` │ ${this.context}` : '';
|
||||
|
||||
console.group(`${timestamp}TABLE${context} │ ${label}`);
|
||||
console.table(data);
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const logger = new Logger();
|
||||
|
||||
export { Logger };
|
||||
23
src/lib/mantine/color-scheme-provider.tsx
Normal file
23
src/lib/mantine/color-scheme-provider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useMantineTheme } from "@mantine/core";
|
||||
import { useMantineColorScheme } from "@mantine/core";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { metadata: { colorScheme } } = useAuth()
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorScheme) return;
|
||||
|
||||
setColorScheme(colorScheme);
|
||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeColorMeta) {
|
||||
themeColorMeta.setAttribute('content', colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0]);
|
||||
}
|
||||
}, [colorScheme]);
|
||||
return children
|
||||
}
|
||||
|
||||
export default ColorSchemeProvider;
|
||||
59
src/lib/mantine/mantine-provider.tsx
Normal file
59
src/lib/mantine/mantine-provider.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { createTheme, MantineProvider as MantineProviderCore } from "@mantine/core";
|
||||
import ColorSchemeProvider from "./color-scheme-provider";
|
||||
|
||||
const commonInputStyles = {
|
||||
label: {
|
||||
padding: 5
|
||||
},
|
||||
root: {
|
||||
margin: '0'
|
||||
}
|
||||
}
|
||||
|
||||
const theme = createTheme({
|
||||
defaultRadius: 'sm',
|
||||
components: {
|
||||
TextInput: {
|
||||
styles: commonInputStyles
|
||||
},
|
||||
DateTimePicker: {
|
||||
styles: commonInputStyles
|
||||
},
|
||||
Input: {
|
||||
styles: commonInputStyles
|
||||
},
|
||||
Select: {
|
||||
styles: commonInputStyles
|
||||
},
|
||||
Autocomplete: {
|
||||
styles: commonInputStyles
|
||||
},
|
||||
DateTiemPicker: {
|
||||
styles: {
|
||||
root: {
|
||||
zIndex: 1000
|
||||
},
|
||||
input: {
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'red'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { metadata } = useAuth()
|
||||
|
||||
return <MantineProviderCore
|
||||
defaultColorScheme={metadata.colorScheme || 'auto'}
|
||||
theme={{ ...theme, primaryColor: metadata.accentColor || 'blue' }}
|
||||
>
|
||||
<ColorSchemeProvider>
|
||||
{children}
|
||||
</ColorSchemeProvider>
|
||||
</MantineProviderCore>
|
||||
}
|
||||
|
||||
export default MantineProvider;
|
||||
78
src/lib/mantine/themes/default.ts
Normal file
78
src/lib/mantine/themes/default.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Card, Container, createTheme, Paper, rem, Select } from "@mantine/core";
|
||||
import type { MantineThemeOverride } from "@mantine/core";
|
||||
|
||||
const CONTAINER_SIZES: Record<string, string> = {
|
||||
xxs: rem("200px"),
|
||||
xs: rem("300px"),
|
||||
sm: rem("400px"),
|
||||
md: rem("500px"),
|
||||
lg: rem("600px"),
|
||||
xl: rem("1400px"),
|
||||
xxl: rem("1600px"),
|
||||
};
|
||||
|
||||
export const defaultTheme: MantineThemeOverride = createTheme({
|
||||
scale: 1.1,
|
||||
autoContrast: true,
|
||||
fontSizes: {
|
||||
xs: rem("12px"),
|
||||
sm: rem("14px"),
|
||||
md: rem("16px"),
|
||||
lg: rem("18px"),
|
||||
xl: rem("20px"),
|
||||
"2xl": rem("24px"),
|
||||
"3xl": rem("30px"),
|
||||
"4xl": rem("36px"),
|
||||
"5xl": rem("48px"),
|
||||
},
|
||||
spacing: {
|
||||
"3xs": rem("4px"),
|
||||
"2xs": rem("8px"),
|
||||
xs: rem("10px"),
|
||||
sm: rem("12px"),
|
||||
md: rem("16px"),
|
||||
lg: rem("20px"),
|
||||
xl: rem("24px"),
|
||||
"2xl": rem("28px"),
|
||||
"3xl": rem("32px"),
|
||||
},
|
||||
primaryColor: "red",
|
||||
components: {
|
||||
Container: Container.extend({
|
||||
vars: (_, { size, fluid }) => ({
|
||||
root: {
|
||||
"--container-size": fluid
|
||||
? "100%"
|
||||
: size !== undefined && size in CONTAINER_SIZES
|
||||
? CONTAINER_SIZES[size]
|
||||
: rem(size),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
Paper: Paper.extend({
|
||||
defaultProps: {
|
||||
p: "md",
|
||||
shadow: "xl",
|
||||
radius: "md",
|
||||
withBorder: true,
|
||||
},
|
||||
}),
|
||||
|
||||
Card: Card.extend({
|
||||
defaultProps: {
|
||||
p: "xl",
|
||||
shadow: "xl",
|
||||
radius: "var(--mantine-radius-default)",
|
||||
withBorder: true,
|
||||
},
|
||||
}),
|
||||
Select: Select.extend({
|
||||
defaultProps: {
|
||||
checkIconPosition: "right",
|
||||
},
|
||||
}),
|
||||
},
|
||||
other: {
|
||||
style: "mantine",
|
||||
},
|
||||
});
|
||||
49
src/lib/pocketbase/client.ts
Normal file
49
src/lib/pocketbase/client.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
import { createPlayersService } from './services/players';
|
||||
import { createTournamentsService } from './services/tournaments';
|
||||
import { createTeamsService } from './services/teams';
|
||||
|
||||
class PocketBaseAdminClient {
|
||||
private pb: PocketBase;
|
||||
private authPromise: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this.pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
|
||||
|
||||
this.pb.beforeSend = (url, options) => {
|
||||
options.cache = 'no-store';
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
};
|
||||
|
||||
return { url, options };
|
||||
};
|
||||
this.pb.autoCancellation(false);
|
||||
|
||||
this.authPromise = this.authenticate();
|
||||
|
||||
this.authPromise.then(() => {
|
||||
Object.assign(this, createPlayersService(this.pb));
|
||||
Object.assign(this, createTeamsService(this.pb));
|
||||
Object.assign(this, createTournamentsService(this.pb));
|
||||
});
|
||||
}
|
||||
|
||||
private async authenticate() {
|
||||
await this.pb.collection("_superusers").authWithPassword(
|
||||
import.meta.env.VITE_POCKETBASE_ADMIN_EMAIL!,
|
||||
import.meta.env.VITE_POCKETBASE_ADMIN_PASSWORD!
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface AdminClient extends
|
||||
PocketBaseAdminClient,
|
||||
ReturnType<typeof createPlayersService>,
|
||||
ReturnType<typeof createTeamsService>,
|
||||
ReturnType<typeof createTournamentsService> {}
|
||||
|
||||
export const pbAdmin = new PocketBaseAdminClient() as AdminClient;
|
||||
46
src/lib/pocketbase/services/players.ts
Normal file
46
src/lib/pocketbase/services/players.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Player, PlayerInput, PlayerUpdateInput } from '@/features/players/types';
|
||||
import { transformPlayer } from '@/lib/pocketbase/util/transform-types';
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
export function createPlayersService(pb: PocketBase) {
|
||||
return {
|
||||
async getPlayerByAuthId(authId: string): Promise<Player | null> {
|
||||
const result = await pb.collection('players').getList<Player>(1, 1, {
|
||||
filter: `auth_id = "${authId}"`
|
||||
});
|
||||
return result.items[0] ? transformPlayer(result.items[0]) : null;
|
||||
},
|
||||
|
||||
async getPlayer(id: string): Promise<Player | null> {
|
||||
const result = await pb.collection('players').getOne(id, {
|
||||
expand: 'teams'
|
||||
});
|
||||
return transformPlayer(result);
|
||||
},
|
||||
|
||||
async listPlayers(): Promise<Player[]> {
|
||||
const result = await pb.collection('players').getFullList<Player>({
|
||||
fields: 'id,first_name,last_name'
|
||||
});
|
||||
return result.map(transformPlayer);
|
||||
},
|
||||
|
||||
async createPlayer(data: PlayerInput): Promise<Player> {
|
||||
const result = await pb.collection('players').create<Player>(data);
|
||||
return transformPlayer(result);
|
||||
},
|
||||
|
||||
async updatePlayer(id: string, data: PlayerUpdateInput): Promise<Player> {
|
||||
const result = await pb.collection('players').update<Player>(id, data);
|
||||
return transformPlayer(result);
|
||||
},
|
||||
|
||||
async getUnassociatedPlayers(): Promise<Player[]> {
|
||||
const result = await pb.collection('players').getFullList<Player>({
|
||||
filter: 'auth_id = ""',
|
||||
fields: 'id,first_name,last_name'
|
||||
});
|
||||
return result.map(transformPlayer);
|
||||
}
|
||||
};
|
||||
}
|
||||
20
src/lib/pocketbase/services/teams.ts
Normal file
20
src/lib/pocketbase/services/teams.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import PocketBase from "pocketbase";
|
||||
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
|
||||
import { Team } from "@/features/teams/types";
|
||||
|
||||
export function createTeamsService(pb: PocketBase) {
|
||||
return {
|
||||
async getTeam(id: string): Promise<Team | null> {
|
||||
try {
|
||||
logger.info('PocketBase | Getting team', id);
|
||||
const result = await pb.collection('teams').getOne(id, {
|
||||
expand: 'players, tournaments'
|
||||
});
|
||||
return transformTeam(result);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
57
src/lib/pocketbase/services/tournaments.ts
Normal file
57
src/lib/pocketbase/services/tournaments.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { Tournament, TournamentInput, TournamentUpdateInput } from "@/features/tournaments/types";
|
||||
import PocketBase from "pocketbase";
|
||||
import { transformTournament } from "@/lib/pocketbase/util/transform-types";
|
||||
import { BracketGenerator } from "@/features/bracket/bracket";
|
||||
|
||||
export function createTournamentsService(pb: PocketBase) {
|
||||
return {
|
||||
async getTournament(id: string): Promise<Tournament | null> {
|
||||
try {
|
||||
const generator = new BracketGenerator(12, true);
|
||||
|
||||
console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=--=-=-=-=-=-=-=-=-=-")
|
||||
console.log('Winners Bracket:');
|
||||
generator.bracket.forEach((round, i) => {
|
||||
console.log(`Round ${i}:`);
|
||||
round.forEach(match => {
|
||||
console.log('-', match);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('\nLosers Bracket:');
|
||||
generator.losersBracket.forEach((round, i) => {
|
||||
console.log(`Round ${i}:`);
|
||||
round.forEach(match => {
|
||||
console.log('-', match);
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('PocketBase | Getting tournament', id);
|
||||
const result = await pb.collection('tournaments').getOne(id, {
|
||||
expand: 'teams, teams.players'
|
||||
});
|
||||
return transformTournament(result);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async listTournaments(): Promise<Tournament[]> {
|
||||
const result = await pb.collection('tournaments').getFullList<Tournament>({
|
||||
fields: 'id,name,start_time,end_time,logo_url,created',
|
||||
sort: '-created'
|
||||
});
|
||||
|
||||
console.log(result)
|
||||
return result.map(transformTournament);
|
||||
},
|
||||
async createTournament(data: TournamentInput): Promise<Tournament> {
|
||||
const result = await pb.collection('tournaments').create<Tournament>(data);
|
||||
return transformTournament(result);
|
||||
},
|
||||
async updateTournament(id: string, data: TournamentUpdateInput): Promise<Tournament> {
|
||||
const result = await pb.collection('tournaments').update<Tournament>(id, data);
|
||||
return transformTournament(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
69
src/lib/pocketbase/util/transform-types.ts
Normal file
69
src/lib/pocketbase/util/transform-types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Player } from "@/features/players/types";
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
|
||||
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
||||
// this file transforms raw pocketbase results to our types
|
||||
|
||||
export function transformPlayer(record: any): Player {
|
||||
const sadf: string[] = [];
|
||||
const teams = record.expand?.teams
|
||||
?.sort((a: Team, b: Team) => new Date(a.created) < new Date(b.created) ? -1 : 0)
|
||||
?.map(transformTeam) ?? [];
|
||||
|
||||
return {
|
||||
id: record.id!,
|
||||
first_name: record.first_name,
|
||||
last_name: record.last_name,
|
||||
auth_id: record.auth_id,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
teams,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformTeam(record: any): Team {
|
||||
const players = record.expand?.players
|
||||
?.sort((a: Player, b: Player) => new Date(a.created!) < new Date(b.created!) ? -1 : 0)
|
||||
?.map(transformPlayer) ?? [];
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
logo_url: record.logo_url,
|
||||
primary_color: record.primary_color,
|
||||
accent_color: record.accent_color,
|
||||
song_id: record.song_id,
|
||||
song_name: record.song_name,
|
||||
song_artist: record.song_artist,
|
||||
song_album: record.song_album,
|
||||
song_year: record.song_year,
|
||||
song_start: record.song_start,
|
||||
song_end: 0,
|
||||
song_image_url: record.song_image_url,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
players,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformTournament(record: any): Tournament {
|
||||
const teams = record.expand?.teams
|
||||
?.sort((a: Team, b: Team) => new Date(a.created) < new Date(b.created) ? -1 : 0)
|
||||
?.map(transformTeam) ?? [];
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
location: record.location,
|
||||
desc: record.desc,
|
||||
rules: record.rules,
|
||||
logo_url: record.logo_url,
|
||||
enroll_time: record.enroll_time,
|
||||
start_time: record.start_time,
|
||||
end_time: record.end_time,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
teams,
|
||||
};
|
||||
}
|
||||
48
src/lib/sonner/index.tsx
Normal file
48
src/lib/sonner/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { toast as sonnerToast } from 'sonner';
|
||||
import { ToastProps } from './types';
|
||||
import { Notification } from '@mantine/core';
|
||||
import { ShieldCheckIcon, WarningCircleIcon } from '@phosphor-icons/react';
|
||||
|
||||
const makeToast = (toast: Omit<ToastProps, 'id'>) => {
|
||||
return sonnerToast.custom((id) => (
|
||||
<Toast
|
||||
id={id}
|
||||
title={toast.title}
|
||||
description={toast.description}
|
||||
withCloseButton={toast.withCloseButton}
|
||||
icon={toast.icon}
|
||||
color={toast.color}
|
||||
loading={!!toast.loading}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function success(toast: Omit<ToastProps, 'id'> | string) {
|
||||
const config = typeof toast === 'string' ? { description: toast } : toast;
|
||||
return makeToast({ ...config, icon: <ShieldCheckIcon color='lightgreen' size={48} weight='fill'/> });
|
||||
}
|
||||
|
||||
function error(toast: Omit<ToastProps, 'id'> | string) {
|
||||
const config = typeof toast === 'string' ? { description: toast } : toast;
|
||||
return makeToast({ ...config, icon: <WarningCircleIcon color='lightcoral' size={48} weight='fill' /> });
|
||||
}
|
||||
|
||||
function Toast(props: ToastProps) {
|
||||
const { title, description, withCloseButton, icon, loading } = props;
|
||||
|
||||
return (
|
||||
<Notification miw='md' color={'rgba(0,0,0,0)'} withBorder
|
||||
withCloseButton={!!withCloseButton}
|
||||
loading={loading}
|
||||
title={title}
|
||||
icon={icon}
|
||||
>
|
||||
{description}
|
||||
</Notification>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
success,
|
||||
error,
|
||||
}
|
||||
11
src/lib/sonner/types.ts
Normal file
11
src/lib/sonner/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { MantineColor } from "@mantine/core";
|
||||
|
||||
export interface ToastProps {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
withCloseButton?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
color?: MantineColor;
|
||||
loading?: boolean;
|
||||
}
|
||||
38
src/lib/supertokens/client.ts
Normal file
38
src/lib/supertokens/client.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import SuperTokens from 'supertokens-web-js';
|
||||
import Session from 'supertokens-web-js/recipe/session';
|
||||
import Passwordless from 'supertokens-web-js/recipe/passwordless';
|
||||
import { appInfo } from './config';
|
||||
import { logger } from './';
|
||||
|
||||
export const frontendConfig = () => {
|
||||
return {
|
||||
appInfo,
|
||||
recipeList: [
|
||||
Passwordless.init(),
|
||||
Session.init({
|
||||
tokenTransferMethod: "cookie",
|
||||
sessionTokenBackendDomain: undefined,
|
||||
|
||||
preAPIHook: async (context) => {
|
||||
context.requestInit.credentials = "include";
|
||||
return context;
|
||||
},
|
||||
})
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
export function ensureSuperTokensFrontend() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (!initialized) {
|
||||
SuperTokens.init(frontendConfig());
|
||||
initialized = true;
|
||||
logger.info("Initialized");
|
||||
|
||||
Session.doesSessionExist().then(exists => {
|
||||
logger.info(`Session does${exists ? '' : 'NOT'} exist on load!`);
|
||||
});
|
||||
}
|
||||
}
|
||||
7
src/lib/supertokens/config.ts
Normal file
7
src/lib/supertokens/config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const appInfo = {
|
||||
appName: 'Tanstack Start SuperTokens',
|
||||
apiDomain: import.meta.env.VITE_API_DOMAIN || 'http://localhost:3000',
|
||||
websiteDomain: import.meta.env.VITE_WEBSITE_DOMAIN || 'http://localhost:3000',
|
||||
apiBasePath: '/api/auth',
|
||||
websiteBasePath: '/auth',
|
||||
}
|
||||
4
src/lib/supertokens/index.ts
Normal file
4
src/lib/supertokens/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Logger } from "@/lib/logger";
|
||||
const logger = new Logger('SuperTokens');
|
||||
|
||||
export { logger };
|
||||
30
src/lib/supertokens/recipes/passwordless-development-mode.ts
Normal file
30
src/lib/supertokens/recipes/passwordless-development-mode.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
import { logger } from "../";
|
||||
|
||||
const init = () => (
|
||||
Passwordless.init({
|
||||
flowType: "USER_INPUT_CODE",
|
||||
contactMethod: "PHONE",
|
||||
smsDelivery: {
|
||||
override: (originalImplementation) => {
|
||||
return {
|
||||
...originalImplementation,
|
||||
sendSms: async ({ userInputCode }) => {
|
||||
if (!userInputCode) {
|
||||
throw new Error("No user input code provided to sendSms");
|
||||
}
|
||||
|
||||
logger.info('Sending Code',
|
||||
'######################',
|
||||
'## SuperTokens Code ##',
|
||||
`## ${userInputCode} ##`,
|
||||
'######################'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export default { init };
|
||||
65
src/lib/supertokens/recipes/passwordless-twilio-verify.ts
Normal file
65
src/lib/supertokens/recipes/passwordless-twilio-verify.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useSession } from "@tanstack/react-start/server";
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
import { sendVerifyCode, updateVerify } from "@/lib/twilio";
|
||||
|
||||
const init = () => (
|
||||
Passwordless.init({
|
||||
flowType: "USER_INPUT_CODE",
|
||||
contactMethod: "PHONE",
|
||||
smsDelivery: {
|
||||
override: (originalImplementation) => {
|
||||
return {
|
||||
...originalImplementation,
|
||||
sendSms: async ({ userInputCode, phoneNumber, preAuthSessionId }) => {
|
||||
if (!userInputCode) {
|
||||
throw new Error("No user input code provided to sendSms");
|
||||
}
|
||||
|
||||
const sid = await sendVerifyCode(phoneNumber, userInputCode);
|
||||
|
||||
const session = await useSession({
|
||||
password: preAuthSessionId
|
||||
});
|
||||
|
||||
await session.update({
|
||||
twilioSid: sid
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
override: {
|
||||
functions: (originalImplementation) => {
|
||||
return {
|
||||
...originalImplementation,
|
||||
consumeCode: async (input) => {
|
||||
const session = await useSession({ password: input.preAuthSessionId });
|
||||
const twilioSid = session?.data.twilioSid;
|
||||
|
||||
if (!twilioSid) {
|
||||
throw new Error("Twilio SID not found in session");
|
||||
}
|
||||
|
||||
let response = await originalImplementation.consumeCode(input);
|
||||
|
||||
if (response.status === "OK") {
|
||||
await updateVerify(twilioSid);
|
||||
await session.update({
|
||||
twilioSid: undefined,
|
||||
userId: response?.user.id
|
||||
})
|
||||
} else if (response.status === "INCORRECT_USER_INPUT_CODE_ERROR") {
|
||||
if (response.failedCodeInputAttemptCount !== response.maximumCodeInputAttempts) {
|
||||
await updateVerify(twilioSid);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export default { init };
|
||||
48
src/lib/supertokens/recipes/start-session.ts
Normal file
48
src/lib/supertokens/recipes/start-session.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getSessionForSSR } from "supertokens-node/custom";
|
||||
import { ensureSuperTokensBackend } from "../server";
|
||||
import { logger } from "../";
|
||||
|
||||
export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) {
|
||||
ensureSuperTokensBackend();
|
||||
|
||||
try {
|
||||
const session = await getSessionForSSR(request);
|
||||
|
||||
if (session.hasToken) {
|
||||
return {
|
||||
hasToken: true,
|
||||
accessTokenPayload: session.accessTokenPayload,
|
||||
userId: session.accessTokenPayload?.sub,
|
||||
sessionHandle: session.accessTokenPayload?.sessionHandle,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
logger.error("Session error", error);
|
||||
|
||||
if (error.type === "TRY_REFRESH_TOKEN") {
|
||||
return {
|
||||
hasToken: false,
|
||||
needsRefresh: true,
|
||||
error: 'TRY_REFRESH_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
if (options?.sessionRequired === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySession(request: Request, options?: { sessionRequired?: boolean }) {
|
||||
const session = await getSessionForStart(request, options);
|
||||
|
||||
if (!session && options?.sessionRequired !== false) {
|
||||
throw new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
42
src/lib/supertokens/server.ts
Normal file
42
src/lib/supertokens/server.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import SuperTokens from "supertokens-node";
|
||||
import Session from "supertokens-node/recipe/session";
|
||||
import { TypeInput } from "supertokens-node/types";
|
||||
import Dashboard from "supertokens-node/recipe/dashboard";
|
||||
import UserRoles from "supertokens-node/recipe/userroles";
|
||||
import { appInfo } from "./config";
|
||||
import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode";
|
||||
import { logger } from "./";
|
||||
|
||||
export const backendConfig = (): TypeInput => {
|
||||
return {
|
||||
framework: 'custom',
|
||||
supertokens: {
|
||||
connectionURI: import.meta.env.VITE_SUPERTOKENS_URI || "https://try.supertokens.io",
|
||||
},
|
||||
appInfo,
|
||||
recipeList: [
|
||||
PasswordlessDevelopmentMode.init(),
|
||||
Session.init({
|
||||
cookieSameSite: "lax",
|
||||
cookieSecure: process.env.NODE_ENV === 'production',
|
||||
cookieDomain: process.env.NODE_ENV === 'production' ? ".example.com" : undefined,
|
||||
antiCsrf: process.env.NODE_ENV === 'production' ? "VIA_TOKEN" : "NONE",
|
||||
|
||||
// Debug only
|
||||
exposeAccessTokenToFrontendInCookieBasedAuth: true,
|
||||
}),
|
||||
Dashboard.init(),
|
||||
UserRoles.init()
|
||||
],
|
||||
telemetry: process.env.NODE_ENV !== 'production',
|
||||
};
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
export function ensureSuperTokensBackend() {
|
||||
if (!initialized) {
|
||||
SuperTokens.init(backendConfig());
|
||||
initialized = true;
|
||||
logger.simple("Backend initialized");
|
||||
}
|
||||
}
|
||||
34
src/lib/twilio/index.ts
Normal file
34
src/lib/twilio/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import twilio from "twilio";
|
||||
|
||||
const accountSid = import.meta.env.VITE_TWILIO_ACCOUNT_SID
|
||||
const authToken = import.meta.env.VITE_TWILIO_AUTH_TOKEN
|
||||
const serviceSid = import.meta.env.VITE_TWILIO_SERVICE_SID
|
||||
|
||||
const client = twilio(accountSid, authToken);
|
||||
|
||||
export async function sendVerifyCode(phoneNumber: string, code: string) {
|
||||
const verification = await client.verify.v2
|
||||
.services(serviceSid)
|
||||
.verifications.create({
|
||||
channel: "sms",
|
||||
customCode: code,
|
||||
to: phoneNumber,
|
||||
});
|
||||
|
||||
if (verification.status !== "pending") {
|
||||
throw new Error("Unknown error sending verification code");
|
||||
}
|
||||
|
||||
return verification.sid;
|
||||
}
|
||||
|
||||
export async function updateVerify(sid: string) {
|
||||
const verification = await client.verify.v2
|
||||
.services(serviceSid)
|
||||
.verifications(sid)
|
||||
.update({ status: "approved" });
|
||||
|
||||
if (verification.status !== "approved") {
|
||||
throw new Error("Unknown error updating verification");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user